feat(ui): add shared copy button component

This commit is contained in:
peaklabs-dev
2026-08-19 15:11:34 +02:00
parent a4886f6dfb
commit 4ee59442ce
3 changed files with 48 additions and 17 deletions
+2
View File
@@ -1,3 +1,4 @@
import { initializeCopyButtonComponent } from './copy-button.js';
import { initializeTerminalComponent } from './terminal.js';
// Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate
@@ -12,6 +13,7 @@ document.addEventListener('livewire:navigated', () => {
// Keeping this registration independent from the current route also makes it
// available before Alpine processes terminal markup after wire:navigate.
document.addEventListener('alpine:init', initializeTerminalComponent);
document.addEventListener('alpine:init', initializeCopyButtonComponent);
/**
* Smooth-scroll a settings section into view, then flash its border for 500ms
+35
View File
@@ -0,0 +1,35 @@
// Alpine data provider for the <x-copy-button> component (x-data="copyButton").
export function initializeCopyButtonComponent() {
window.Alpine.data('copyButton', () => ({
copied: false,
async copy(value) {
if (value === null || value === undefined) {
window.toast('Value is not available.', { type: 'warning' });
return;
}
try {
if (navigator.clipboard?.writeText && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
// Deprecated, but the only copy path on plain http (non-secure contexts).
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
if (!ok) {
throw new Error('Copy command was rejected.');
}
}
this.copied = true;
setTimeout(() => (this.copied = false), 1200);
} catch (e) {
window.toast('Could not copy to clipboard.', { type: 'warning' });
}
},
}));
}
@@ -1,22 +1,16 @@
@props([
'value',
'value' => null,
'resolve' => null,
'label' => 'Copy to clipboard',
])
<button type="button"
x-data="{ copied: false }"
x-on:click.prevent.stop="await window.copyToClipboard({{ Js::from($value) }}); copied = true; setTimeout(() => copied = false, 1000)"
{{ $attributes->class('inline-flex size-6 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-40 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-white') }}
title="{{ $label }}" aria-label="{{ $label }}" @disabled(blank($value))>
<svg x-show="!copied" class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor"
aria-hidden="true">
<path d="M8 8.75H6.5A2.25 2.25 0 0 0 4.25 11v6.5a2.25 2.25 0 0 0 2.25 2.25H13a2.25 2.25 0 0 0 2.25-2.25V16"
stroke-width="1.5" stroke-linecap="round" />
<rect x="8.75" y="4.25" width="11" height="11" rx="2.25" stroke-width="1.5" />
</svg>
<svg x-show="copied" x-cloak class="size-3.5 text-green-500" viewBox="0 0 24 24" fill="none"
stroke="currentColor" aria-hidden="true">
<path d="m6.75 12.25 3.5 3.5 7-7" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
@php
$valueExpression = $resolve ?? \Illuminate\Support\Js::from($value);
@endphp
<button type="button" title="{{ $label }}" aria-label="{{ $label }}"
{{ $attributes->class(['icon-button shrink-0']) }} @disabled($resolve === null && blank($value))
x-data="copyButton" @click="copy(await ({{ $valueExpression }}))">
<x-reicon name="copy" x-show="!copied" class="size-3.5" />
<x-reicon name="check" x-cloak x-show="copied" class="size-3.5 text-success" />
</button>