@c2n/copy-button 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Nguyen Thai Vinh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @c2n/copy-button
2
+
3
+ Copy button built with Lit: puts text on the clipboard — the element it sits in, another element by `id`, or a literal string.
4
+
5
+ ```bash
6
+ npm install @c2n/copy-button
7
+ ```
8
+
9
+ ```html
10
+ <script type="module">
11
+ import '@c2n/copy-button'
12
+ </script>
13
+
14
+ <!-- Copies its parent, pinned in the corner, revealed on hover. -->
15
+ <pre style="position: relative">npx -y @c2n/mcp<c2-copy-button pin></c2-copy-button></pre>
16
+
17
+ <!-- Copies another element by id. -->
18
+ <p id="address">548 Market St, San Francisco, CA 94104</p>
19
+ <c2-copy-button for="address" reveal="always">Copy address</c2-copy-button>
20
+
21
+ <!-- Copies an exact string. -->
22
+ <c2-copy-button value="c2n_live_8f4c19ab7e2d" reveal="always">Copy token</c2-copy-button>
23
+ ```
24
+
25
+ - **Source**: `value` (a literal string) wins, then `for` (the `id` of the source element, looked up in the containing document or shadow root), then the light-DOM parent. `parentElement` is deliberate: a button slotted into another custom element copies the element it was authored inside, not the shadow-DOM node it renders in.
26
+ - **Reading a source**: each source is read the way it stores its text — the `source` of a `c2-code-viewer` (the real code, not the highlighted token spans), the `value` of an `<input>`, `<textarea>` or `<select>`, and the rendered text of anything else. Any `c2-copy-button` inside the source is skipped, so a button sitting in the block it copies never copies its own label. Inner whitespace is preserved (newlines in a `<pre>` survive); only the leading and trailing whitespace is trimmed.
27
+ - **Reveal**: `reveal="hover"` is the default — the button is transparent until the pointer enters its source, so a corner button does not sit on top of the content it copies. Only the opacity changes, so it keeps its box and stays reachable: it also appears when focus lands anywhere inside the source, on devices with no hover at all (where a hover-only button would be unreachable), and for as long as the `copied` confirmation shows. While hidden it takes no pointer events, so there is never an invisible click target. `reveal="always"` shows it permanently.
28
+ - **Pin**: `pin` absolutely positions the button in a corner of its containing block — the bare attribute means top right, or name one of `top-right`, `top-left`, `bottom-right`, `bottom-left` — inset by `--c2-copy-button__pin--offset-x` / `-y`. Give the element it sits in `position: relative`, as with any absolutely positioned child. A pinned button also **stays put while its source scrolls**: an absolutely positioned child of a scrolling box lives in that box's scrollable overflow and would otherwise scroll away with the content, exactly when it is needed, since long or wide text is why there is a scrollbar at all. It finds the nearest ancestor that can scroll and cancels out its scroll offset, in both directions. The lookup is keyed on the overflow style rather than on whether the box currently overflows, so content that grows later (a webfont finishing) cannot leave the button attached to nothing.
29
+ - **Feedback**: after a successful copy the icon becomes a check and the `copied` attribute is set for `copied-duration` ms (default `2000`), then cleared. `copied` is reflected, so `c2-copy-button[copied]` is styleable from outside, and the `__container__copied--*` variables cover it from within.
30
+ - **Events**: `copied` carries `detail.text`; `copy-error` carries `detail.error` when the clipboard write is refused, so a failure is never silent. The async `copy()` method returns `false` in that case.
31
+ - **Slots**: the default slot is an optional label beside the icon — leave it empty for an icon-only, square button. `copy-icon` and `copied-icon` replace the two glyphs; `copied-label` replaces the text while the copied state shows.
32
+ - **Accessibility**: renders a real `<button>` with focus delegated to it. The accessible name comes from `label` / `copied-label` (default "Copy" / "Copied"), and the state change is announced through a visually hidden `role="status"` region, because an icon swap alone is not read out.
33
+ - **Fallback**: where `navigator.clipboard` is unavailable — any non-secure context, which includes a plain-http dev server — it falls back to an off-screen `<textarea>` and `document.execCommand('copy')` rather than doing nothing.
34
+
35
+ Everything is themed through CSS custom properties: `__container--*` for the button box (size, padding, radius, border, colours, typography) with `__hover`, `__active`, `__focus`, `__disabled` and `__copied` states, `__icon--*` for the glyph, `__pin--offset-x` / `-y` for the pinned inset and `--c2-copy-button--transition-duration` for the reveal fade. The full list is in `custom-elements.json` and on the docs site.
@@ -0,0 +1,367 @@
1
+ import { LitElement, html, nothing, unsafeCSS } from "lit";
2
+ import { customElement, property, state } from "lit/decorators.js";
3
+ import { classMap } from "lit/directives/class-map.js";
4
+ //#region src/copy-button.scss?inline
5
+ var copy_button_default = "/* ex : var((width: 24px), width, c2-checkbox) returns var(--c2-checkbox-width, 24px) */\n:host {\n display: inline-flex;\n}\n\n:host([hidden]) {\n display: none;\n}\n\n:host([pin]) {\n position: absolute;\n top: var(--c2-copy-button__pin--offset-y,6px);\n right: var(--c2-copy-button__pin--offset-x,6px);\n transform: translate(var(--_c2-copy-button-scroll-x, 0px), var(--_c2-copy-button-scroll-y, 0px));\n}\n\n:host([pin=top-left]) {\n right: auto;\n left: var(--c2-copy-button__pin--offset-x,6px);\n}\n\n:host([pin=bottom-right]) {\n top: auto;\n bottom: var(--c2-copy-button__pin--offset-y,6px);\n}\n\n:host([pin=bottom-left]) {\n top: auto;\n bottom: var(--c2-copy-button__pin--offset-y,6px);\n right: auto;\n left: var(--c2-copy-button__pin--offset-x,6px);\n}\n\n:host([disabled]) {\n pointer-events: none;\n opacity: var(--c2-copy-button__container__disabled--opacity,0.38);\n}\n\n.c2-copy-button {\n appearance: none;\n box-sizing: border-box;\n margin: 0;\n font: inherit;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: var(--c2-copy-button__container--gap,6px);\n height: var(--c2-copy-button__container--height,28px);\n min-width: var(--c2-copy-button__container--min-width,28px);\n padding: 0 var(--c2-copy-button__container--padding-right,6px) 0 var(--c2-copy-button__container--padding-left,6px);\n border: var(--c2-copy-button__container--border,none);\n border-radius: var(--c2-copy-button__container--border-radius,6px);\n background-color: var(--c2-copy-button__container--background-color);\n color: var(--c2-copy-button__container--color,rgb(34, 34, 34));\n font-size: var(--c2-copy-button__container--font-size,12px);\n font-weight: var(--c2-copy-button__container--font-weight,500);\n font-family: var(--c2-copy-button__container--font-family);\n cursor: pointer;\n user-select: none;\n transition: background-color 250ms cubic-bezier(0.2, 0, 0, 1), color 250ms cubic-bezier(0.2, 0, 0, 1);\n}\n.c2-copy-button:hover {\n background-color: var(--c2-copy-button__container__hover--background-color,rgb(230, 230, 230));\n color: var(--c2-copy-button__container__hover--color,var(--c2-copy-button__container--color,rgb(34, 34, 34)));\n border: var(--c2-copy-button__container__hover--border,var(--c2-copy-button__container--border,none));\n}\n.c2-copy-button:active {\n background-color: var(--c2-copy-button__container__active--background-color,var(--c2-copy-button__container__hover--background-color,rgb(213, 213, 213)));\n}\n.c2-copy-button:focus-visible {\n outline: var(--c2-copy-button__container__focus--outline,2px solid rgba(2, 101, 220, 0.4));\n outline-offset: var(--c2-copy-button__container__focus--outline-offset,2px);\n}\n.c2-copy-button:disabled {\n cursor: default;\n}\n.c2-copy-button.is-copied {\n color: var(--c2-copy-button__container__copied--color,rgb(0, 122, 77));\n background-color: var(--c2-copy-button__container__copied--background-color,var(--c2-copy-button__container--background-color));\n border: var(--c2-copy-button__container__copied--border,var(--c2-copy-button__container--border,none));\n}\n.c2-copy-button.is-failed {\n color: rgb(211, 21, 16);\n}\n\n:host(:not([reveal=always])) .c2-copy-button {\n opacity: 0;\n pointer-events: none;\n transition: opacity var(--c2-copy-button--transition-duration,150ms) cubic-bezier(0.2, 0, 0, 1);\n}\n\n:host(:not([reveal=always])) .c2-copy-button.is-visible {\n opacity: 1;\n pointer-events: auto;\n}\n\n@media (prefers-reduced-motion: reduce) {\n :host(:not([reveal=always])) .c2-copy-button {\n transition: none;\n }\n}\n.c2-copy-button-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: none;\n width: var(--c2-copy-button__icon--size,16px);\n height: var(--c2-copy-button__icon--size,16px);\n color: var(--c2-copy-button__icon--color);\n transition: color 250ms cubic-bezier(0.2, 0, 0, 1);\n}\n.c2-copy-button-icon svg,\n.c2-copy-button-icon ::slotted(*) {\n width: 100%;\n height: 100%;\n --c2-feather-icon--size: var(--c2-copy-button__icon--size,16px);\n --c2-mat-icon--font-size: var(--c2-copy-button__icon--size,16px);\n}\n\n.c2-copy-button.is-copied .c2-copy-button-icon {\n color: var(--c2-copy-button__icon__copied--color,var(--c2-copy-button__container__copied--color,rgb(0, 122, 77)));\n}\n\n.c2-copy-button-label {\n display: contents;\n white-space: nowrap;\n}\n\n.c2-copy-button-status {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n}";
6
+ //#endregion
7
+ //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorate.js
8
+ function __decorate(decorators, target, key, desc) {
9
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
10
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
11
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
12
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
13
+ }
14
+ //#endregion
15
+ //#region src/copy-button.ts
16
+ /** Elements that carry their text on a property rather than in their child nodes. */
17
+ var TEXT_PROPERTY_TAGS = [
18
+ "input",
19
+ "textarea",
20
+ "select"
21
+ ];
22
+ var SCROLLABLE_OVERFLOW = [
23
+ "auto",
24
+ "scroll",
25
+ "overlay"
26
+ ];
27
+ var CopyButton = class CopyButton extends LitElement {
28
+ constructor(..._args) {
29
+ super(..._args);
30
+ this.value = void 0;
31
+ this.for = void 0;
32
+ this.disabled = false;
33
+ this.copied = false;
34
+ this.copiedDuration = 2e3;
35
+ this.label = "Copy";
36
+ this.copiedLabel = "Copied";
37
+ this.reveal = "hover";
38
+ this.pin = void 0;
39
+ this.failed = false;
40
+ this.hovered = false;
41
+ this.focused = false;
42
+ this.hoverTarget = null;
43
+ this.scroller = null;
44
+ this.scrollFrame = 0;
45
+ this.handleHoverQueryChange = () => this.requestUpdate();
46
+ this.handlePointerEnter = () => this.updateReveal(() => this.hovered = true);
47
+ this.handlePointerLeave = () => this.updateReveal(() => this.hovered = false);
48
+ this.handleFocusIn = () => this.updateReveal(() => this.focused = deepActiveElement()?.matches(":focus-visible") ?? false);
49
+ this.handleFocusOut = (event) => this.updateReveal(() => {
50
+ const next = event.relatedTarget;
51
+ if (next instanceof Node && this.hoverTarget?.contains(next)) return;
52
+ this.focused = false;
53
+ });
54
+ this.handleScroll = () => {
55
+ cancelAnimationFrame(this.scrollFrame);
56
+ this.scrollFrame = requestAnimationFrame(() => this.syncPinOffset());
57
+ };
58
+ }
59
+ static {
60
+ this.styles = unsafeCSS(copy_button_default);
61
+ }
62
+ static {
63
+ this.shadowRootOptions = {
64
+ ...LitElement.shadowRootOptions,
65
+ delegatesFocus: true
66
+ };
67
+ }
68
+ connectedCallback() {
69
+ super.connectedCallback();
70
+ this.hoverQuery ??= window.matchMedia?.("(hover: hover)");
71
+ this.hoverQuery?.addEventListener("change", this.handleHoverQueryChange);
72
+ }
73
+ disconnectedCallback() {
74
+ super.disconnectedCallback();
75
+ clearTimeout(this.timer);
76
+ cancelAnimationFrame(this.scrollFrame);
77
+ this.hoverQuery?.removeEventListener("change", this.handleHoverQueryChange);
78
+ this.detachHoverTarget();
79
+ this.detachScroller();
80
+ }
81
+ firstUpdated() {
82
+ this.attachHoverTarget();
83
+ this.attachScroller();
84
+ }
85
+ willUpdate(changed) {
86
+ if (changed.has("for") && this.hasUpdated) this.attachHoverTarget();
87
+ if (changed.has("pin") && this.hasUpdated) this.attachScroller();
88
+ }
89
+ /** True when the button should be visible: `always`, no hover support, or the user is actually there. */
90
+ get isVisible() {
91
+ if (this.reveal === "always") return true;
92
+ if (this.hoverQuery && !this.hoverQuery.matches) return true;
93
+ return this.hovered || this.focused;
94
+ }
95
+ /**
96
+ * Resolved fade duration of the reveal transition, in ms.
97
+ *
98
+ * Read off the rendered button rather than off the custom property, so an overridden
99
+ * `--c2-copy-button--transition-duration` and `prefers-reduced-motion: reduce` (which drops it to `0s`) are both
100
+ * accounted for without re-deriving the cascade here.
101
+ */
102
+ get concealDuration() {
103
+ const button = this.renderRoot?.querySelector("button");
104
+ if (!button) return 0;
105
+ const [first = ""] = getComputedStyle(button).transitionDuration.split(",");
106
+ const value = Number.parseFloat(first);
107
+ if (!Number.isFinite(value)) return 0;
108
+ return first.trim().endsWith("ms") ? value : value * 1e3;
109
+ }
110
+ attachHoverTarget() {
111
+ const next = this.sourceElement;
112
+ if (next === this.hoverTarget) return;
113
+ this.detachHoverTarget();
114
+ this.hoverTarget = next;
115
+ if (!next) return;
116
+ next.addEventListener("pointerenter", this.handlePointerEnter);
117
+ next.addEventListener("pointerleave", this.handlePointerLeave);
118
+ next.addEventListener("focusin", this.handleFocusIn);
119
+ next.addEventListener("focusout", this.handleFocusOut);
120
+ }
121
+ detachHoverTarget() {
122
+ const target = this.hoverTarget;
123
+ if (!target) return;
124
+ target.removeEventListener("pointerenter", this.handlePointerEnter);
125
+ target.removeEventListener("pointerleave", this.handlePointerLeave);
126
+ target.removeEventListener("focusin", this.handleFocusIn);
127
+ target.removeEventListener("focusout", this.handleFocusOut);
128
+ this.hoverTarget = null;
129
+ }
130
+ /** Applies a reveal change, re-arming the confirmation timer when the button crosses the visibility line. */
131
+ updateReveal(apply) {
132
+ const wasVisible = this.isVisible;
133
+ apply();
134
+ if (this.isVisible !== wasVisible) this.armResolvedTimer();
135
+ }
136
+ /**
137
+ * (Re)arms the reset of the `copied` / `failed` state.
138
+ *
139
+ * On screen it runs for the full `copied-duration`. Off screen it only has to outlast the fade: the state is
140
+ * cleared once the button is invisible, so the icon swap is never painted. Coming back mid-confirmation restarts
141
+ * the full countdown, so the state can never stick with no timer behind it.
142
+ */
143
+ armResolvedTimer() {
144
+ if (!this.copied && !this.failed) return;
145
+ clearTimeout(this.timer);
146
+ this.timer = setTimeout(() => this.clearResolvedState(), this.isVisible ? this.copiedDuration : this.concealDuration);
147
+ }
148
+ clearResolvedState() {
149
+ this.copied = false;
150
+ this.failed = false;
151
+ }
152
+ attachScroller() {
153
+ this.detachScroller();
154
+ if (!this.pin && this.pin !== "") return;
155
+ this.scroller = nearestScrollable(this);
156
+ this.scroller?.addEventListener("scroll", this.handleScroll, { passive: true });
157
+ this.syncPinOffset();
158
+ }
159
+ detachScroller() {
160
+ this.scroller?.removeEventListener("scroll", this.handleScroll);
161
+ this.scroller = null;
162
+ this.style.removeProperty("--_c2-copy-button-scroll-x");
163
+ this.style.removeProperty("--_c2-copy-button-scroll-y");
164
+ }
165
+ /**
166
+ * Cancels out the scroll offset of the container.
167
+ *
168
+ * A pinned button is an absolutely positioned child of the scroll container, which puts it in the container's
169
+ * scrollable overflow — so it scrolls away with the content, exactly when the user needs it (long or wide text is
170
+ * the reason there is a scrollbar at all). Translating by the scroll offset holds it against the visible box.
171
+ * The same sign works for all four corners: `right`/`bottom` are resolved against the padding box, whose size is
172
+ * the client size, so every corner needs to move *with* the scroll.
173
+ */
174
+ syncPinOffset() {
175
+ const scroller = this.scroller;
176
+ if (!scroller) return;
177
+ this.style.setProperty("--_c2-copy-button-scroll-x", `${scroller.scrollLeft}px`);
178
+ this.style.setProperty("--_c2-copy-button-scroll-y", `${scroller.scrollTop}px`);
179
+ }
180
+ /**
181
+ * The element the text is read from: `for` by id, else the light-DOM parent.
182
+ *
183
+ * `parentElement` is deliberate — a button slotted into another custom element should copy the element it was
184
+ * authored inside, not whatever shadow-DOM node it happens to be rendered in.
185
+ */
186
+ get sourceElement() {
187
+ if (this.for) return this.getRootNode().querySelector?.(`#${CSS.escape(this.for)}`) ?? null;
188
+ return this.parentElement;
189
+ }
190
+ /** The text that would be copied right now. */
191
+ get text() {
192
+ if (this.value !== void 0) return this.value;
193
+ const source = this.sourceElement;
194
+ if (!source) return "";
195
+ return readText(source).trim();
196
+ }
197
+ /** Copies `text` to the clipboard and enters the `copied` state. Returns false when the write failed. */
198
+ async copy() {
199
+ const text = this.text;
200
+ try {
201
+ await writeToClipboard(text);
202
+ } catch (error) {
203
+ this.copied = false;
204
+ this.failed = true;
205
+ this.armResolvedTimer();
206
+ this.dispatchEvent(new CustomEvent("copy-error", {
207
+ detail: { error },
208
+ bubbles: true,
209
+ composed: true
210
+ }));
211
+ return false;
212
+ }
213
+ this.failed = false;
214
+ this.copied = true;
215
+ this.armResolvedTimer();
216
+ this.dispatchEvent(new CustomEvent("copied", {
217
+ detail: { text },
218
+ bubbles: true,
219
+ composed: true
220
+ }));
221
+ return true;
222
+ }
223
+ handleClick() {
224
+ this.copy();
225
+ }
226
+ renderIcon() {
227
+ if (this.copied) return html`<slot name="copied-icon"
228
+ ><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
229
+ <polyline points="20 6 9 17 4 12"></polyline></svg
230
+ ></slot>`;
231
+ return html`<slot name="copy-icon"
232
+ ><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
233
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
234
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg
235
+ ></slot>`;
236
+ }
237
+ render() {
238
+ return html`
239
+ <button
240
+ class=${classMap({
241
+ "c2-copy-button": true,
242
+ "is-copied": this.copied,
243
+ "is-failed": this.failed,
244
+ "is-visible": this.isVisible
245
+ })}
246
+ part="button"
247
+ type="button"
248
+ ?disabled=${this.disabled}
249
+ aria-label=${this.copied ? this.copiedLabel : this.label}
250
+ @click=${this.handleClick}
251
+ >
252
+ <span class="c2-copy-button-icon" part="icon">${this.renderIcon()}</span>
253
+ <span class="c2-copy-button-label"> ${this.copied ? html`<slot name="copied-label"><slot></slot></slot>` : html`<slot></slot>`} </span>
254
+ </button>
255
+ ${html`<span class="c2-copy-button-status" role="status" aria-live="polite"
256
+ >${this.copied ? this.copiedLabel : this.failed ? "Copy failed" : nothing}</span
257
+ >`}
258
+ `;
259
+ }
260
+ };
261
+ __decorate([property()], CopyButton.prototype, "value", void 0);
262
+ __decorate([property()], CopyButton.prototype, "for", void 0);
263
+ __decorate([property({
264
+ type: Boolean,
265
+ reflect: true
266
+ })], CopyButton.prototype, "disabled", void 0);
267
+ __decorate([property({
268
+ type: Boolean,
269
+ reflect: true
270
+ })], CopyButton.prototype, "copied", void 0);
271
+ __decorate([property({
272
+ type: Number,
273
+ attribute: "copied-duration"
274
+ })], CopyButton.prototype, "copiedDuration", void 0);
275
+ __decorate([property()], CopyButton.prototype, "label", void 0);
276
+ __decorate([property({ attribute: "copied-label" })], CopyButton.prototype, "copiedLabel", void 0);
277
+ __decorate([property({ reflect: true })], CopyButton.prototype, "reveal", void 0);
278
+ __decorate([property({ reflect: true })], CopyButton.prototype, "pin", void 0);
279
+ __decorate([state()], CopyButton.prototype, "failed", void 0);
280
+ __decorate([state()], CopyButton.prototype, "hovered", void 0);
281
+ __decorate([state()], CopyButton.prototype, "focused", void 0);
282
+ CopyButton = __decorate([customElement("c2-copy-button")], CopyButton);
283
+ /**
284
+ * The focused element, descending through shadow roots.
285
+ *
286
+ * `document.activeElement` stops at the outermost shadow host, which for a focused copy button is the
287
+ * `c2-copy-button` element itself — and a host never matches `:focus-visible` the way the inner `<button>` does.
288
+ */
289
+ function deepActiveElement() {
290
+ let active = document.activeElement;
291
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
292
+ return active;
293
+ }
294
+ /**
295
+ * The nearest ancestor that can scroll, starting at the button's parent.
296
+ *
297
+ * Deliberately keyed on the overflow *style* and not on whether the box currently overflows: this runs once on
298
+ * first render, and content that grows later (a webfont finishing, a value being set) would otherwise leave the
299
+ * button attached to nothing and scrolling away. A container that never overflows keeps `scrollTop`/`scrollLeft`
300
+ * at 0, so compensating it costs nothing.
301
+ */
302
+ function nearestScrollable(from) {
303
+ let element = from.parentElement;
304
+ while (element && element !== document.body && element !== document.documentElement) {
305
+ const style = getComputedStyle(element);
306
+ if (SCROLLABLE_OVERFLOW.includes(style.overflowY) || SCROLLABLE_OVERFLOW.includes(style.overflowX)) return element;
307
+ element = element.parentElement;
308
+ }
309
+ return null;
310
+ }
311
+ /**
312
+ * Reads the text of a source element the way that element stores it.
313
+ *
314
+ * `c2-code-viewer` keeps the real (dedented) code on `source`, form fields keep it on `value`, and everything else
315
+ * keeps it in its child nodes. Falling back to `textContent` for a code viewer would copy the syntax-highlighted
316
+ * DOM, which is the same characters but assembled from dozens of token spans — and for a field it would copy nothing.
317
+ */
318
+ function readText(element) {
319
+ if (TEXT_PROPERTY_TAGS.includes(element.localName)) return element.value ?? "";
320
+ const source = element.source;
321
+ if (typeof source === "string") return source;
322
+ const text = textExcludingCopyButtons(element);
323
+ if (text.trim()) return text;
324
+ const value = element.value;
325
+ return typeof value === "string" ? value : text;
326
+ }
327
+ /**
328
+ * `textContent` of `element`, minus any `c2-copy-button` inside it.
329
+ *
330
+ * Without this, the common case in the component's own docstring — a button dropped inside the block it copies —
331
+ * would copy the button's own label along with the content.
332
+ */
333
+ function textExcludingCopyButtons(element) {
334
+ let text = "";
335
+ for (const node of element.childNodes) if (node.nodeType === Node.TEXT_NODE) text += node.nodeValue ?? "";
336
+ else if (node.nodeType === Node.ELEMENT_NODE) {
337
+ const child = node;
338
+ if (child.localName === "c2-copy-button") continue;
339
+ text += textExcludingCopyButtons(child);
340
+ }
341
+ return text;
342
+ }
343
+ /**
344
+ * Writes to the clipboard, falling back to a hidden `<textarea>` + `execCommand` when the async Clipboard API is
345
+ * unavailable. That covers non-secure contexts, which includes plain-http dev servers and previews — where
346
+ * `navigator.clipboard` is simply undefined and the button would otherwise never work.
347
+ */
348
+ async function writeToClipboard(text) {
349
+ if (navigator.clipboard?.writeText) {
350
+ await navigator.clipboard.writeText(text);
351
+ return;
352
+ }
353
+ const area = document.createElement("textarea");
354
+ area.value = text;
355
+ area.setAttribute("readonly", "");
356
+ area.setAttribute("aria-hidden", "true");
357
+ area.style.cssText = "position:fixed;top:0;left:-9999px;opacity:0";
358
+ document.body.append(area);
359
+ try {
360
+ area.select();
361
+ if (!document.execCommand("copy")) throw new Error("Copying to the clipboard is not available");
362
+ } finally {
363
+ area.remove();
364
+ }
365
+ }
366
+ //#endregion
367
+ export { CopyButton };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@c2n/copy-button",
3
+ "version": "0.0.6",
4
+ "type": "module",
5
+ "main": "dist/copy-button.js",
6
+ "exports": {
7
+ ".": {
8
+ "default": "./dist/copy-button.js",
9
+ "types": "./types/src/copy-button.d.ts"
10
+ },
11
+ "./custom-elements.json": "./custom-elements.json"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "types"
16
+ ],
17
+ "keywords": [
18
+ "copy-button",
19
+ "web component",
20
+ "lit"
21
+ ],
22
+ "license": "MIT",
23
+ "author": "code2nguyen@gmail.com",
24
+ "publishConfig": {
25
+ "registry": "https://registry.npmjs.org",
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/code2nguyen/web-components.git"
31
+ },
32
+ "scripts": {
33
+ "dev": "vite",
34
+ "build": "wireit",
35
+ "build:only": "vite build",
36
+ "type-check": "wireit"
37
+ },
38
+ "wireit": {
39
+ "type-check": {
40
+ "dependencies": [
41
+ "../../core:build"
42
+ ],
43
+ "command": "tsc -p tsconfig.lib.json --composite false"
44
+ },
45
+ "build": {
46
+ "dependencies": [
47
+ "type-check"
48
+ ],
49
+ "command": "vite build"
50
+ }
51
+ },
52
+ "dependencies": {
53
+ "@c2n/core": "0.0.6",
54
+ "lit": "3.3.3"
55
+ },
56
+ "devDependencies": {
57
+ "@c2n/config": "*"
58
+ },
59
+ "customElements": "custom-elements.json",
60
+ "gitHead": "2921054bc75299da66dad11c1e374246ff88a51e"
61
+ }
@@ -0,0 +1,193 @@
1
+ import { LitElement, type PropertyValues } from 'lit';
2
+ /** Corners `pin` can anchor to. The bare `pin` attribute means `top-right`. */
3
+ export type CopyButtonPin = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
4
+ /**
5
+ * Copies text to the clipboard.
6
+ *
7
+ * Drop it inside the element you want to copy and it copies that element: `<pre><c2-copy-button></c2-copy-button>…</pre>`
8
+ * copies the `<pre>`. Point it somewhere else with `for` (the `id` of the source), or give it the literal text with
9
+ * `value`. It reads the source the way that source stores its text — the `source` of a `c2-code-viewer`, the `value`
10
+ * of an `<input>`/`<textarea>` or of a field that keeps its input in a shadow root (`c2-text-field`), otherwise the
11
+ * rendered text — and never copies its own label, so a button sitting inside its source does not end up in the result.
12
+ *
13
+ * Renders a real `<button>` (focus is delegated to it). With no content it is an icon-only button; slot text to get
14
+ * an icon and a label. After a successful copy it flips to the `copied` state for `copied-duration` ms.
15
+ *
16
+ * By default it only appears while the pointer is over its source (`reveal="always"` opts out), and `pin` puts it in
17
+ * a corner of the source where it stays put even while the source scrolls.
18
+ *
19
+ * @tag c2-copy-button
20
+ *
21
+ * @slot - Optional label beside the icon. Empty by default, which gives an icon-only button.
22
+ * @slot copy-icon - Replaces the default copy icon.
23
+ * @slot copied-icon - Replaces the default check icon shown after a copy.
24
+ * @slot copied-label - Replaces the default slot's text while in the `copied` state.
25
+ *
26
+ * @event {CustomEvent<{ text: string }>} copied - Fired after the text reached the clipboard. `detail.text` is what was copied.
27
+ * @event {CustomEvent<{ error: unknown }>} copy-error - Fired when the clipboard write failed (denied permission, or no clipboard at all).
28
+ *
29
+ * @cssproperty {color} --c2-copy-button__container--background-color
30
+ * @cssproperty {color} [--c2-copy-button__container--color=rgb(34, 34, 34)]
31
+ * @cssproperty {border} [--c2-copy-button__container--border=none]
32
+ * @cssproperty {border-radius} [--c2-copy-button__container--border-radius=6px]
33
+ * @cssproperty {pixel} [--c2-copy-button__container--height=28px]
34
+ * @cssproperty {pixel} [--c2-copy-button__container--min-width=28px]
35
+ * @cssproperty {padding} [--c2-copy-button__container--padding-left=6px]
36
+ * @cssproperty {padding} [--c2-copy-button__container--padding-right=6px]
37
+ * @cssproperty {pixel} [--c2-copy-button__container--gap=6px]
38
+ * @cssproperty {font-size} [--c2-copy-button__container--font-size=12px]
39
+ * @cssproperty {font-weight} [--c2-copy-button__container--font-weight=500]
40
+ * @cssproperty {font-family} --c2-copy-button__container--font-family
41
+ *
42
+ * @cssproperty {color} [--c2-copy-button__container__hover--background-color=rgb(230, 230, 230)]
43
+ * @cssproperty {color} --c2-copy-button__container__hover--color
44
+ * @cssproperty {border} --c2-copy-button__container__hover--border
45
+ * @cssproperty {color} [--c2-copy-button__container__active--background-color=rgb(213, 213, 213)]
46
+ * @cssproperty {outline} [--c2-copy-button__container__focus--outline=2px solid rgba(2, 101, 220, 0.4)]
47
+ * @cssproperty {pixel} [--c2-copy-button__container__focus--outline-offset=2px]
48
+ * @cssproperty {opacity} [--c2-copy-button__container__disabled--opacity=0.38]
49
+ *
50
+ * @cssproperty {color} [--c2-copy-button__container__copied--color=rgb(0, 122, 77)] - Applied for `copied-duration` after a successful copy.
51
+ * @cssproperty {color} --c2-copy-button__container__copied--background-color
52
+ * @cssproperty {border} --c2-copy-button__container__copied--border
53
+ *
54
+ * @cssproperty {pixel} [--c2-copy-button__icon--size=16px]
55
+ * @cssproperty {color} --c2-copy-button__icon--color - Defaults to the button's text colour.
56
+ * @cssproperty {color} --c2-copy-button__icon__copied--color
57
+ *
58
+ * @cssproperty {time} [--c2-copy-button--transition-duration=150ms] - Fade of the hover reveal.
59
+ * @cssproperty {pixel} [--c2-copy-button__pin--offset-x=6px] - Inset from the pinned corner, horizontally.
60
+ * @cssproperty {pixel} [--c2-copy-button__pin--offset-y=6px] - Inset from the pinned corner, vertically.
61
+ */
62
+ export declare class CopyButton extends LitElement {
63
+ static styles: import("lit").CSSResult;
64
+ static shadowRootOptions: {
65
+ delegatesFocus: boolean;
66
+ clonable?: boolean;
67
+ customElementRegistry?: CustomElementRegistry | null;
68
+ mode: ShadowRootMode;
69
+ serializable?: boolean;
70
+ slotAssignment?: SlotAssignmentMode;
71
+ };
72
+ /** Literal text to copy. Takes precedence over `for` and over the parent element. */
73
+ value: string | undefined;
74
+ /** `id` of the element to copy, looked up in the containing document or shadow root. Overrides the parent element. */
75
+ for: string | undefined;
76
+ /** Disables the button: no pointer events, no focus, reduced opacity. */
77
+ disabled: boolean;
78
+ /** True for `copied-duration` after a successful copy. Reflected, so `c2-copy-button[copied]` is styleable. */
79
+ copied: boolean;
80
+ /** How long the `copied` state lasts, in ms. */
81
+ copiedDuration: number;
82
+ /** Accessible name of the button while idle. */
83
+ label: string;
84
+ /** Accessible name (and `copied-label` fallback) after a successful copy. */
85
+ copiedLabel: string;
86
+ /**
87
+ * `hover` (default) keeps the button transparent until the pointer is over its source, or keyboard focus lands
88
+ * inside it. `always` shows it permanently. A hover-only button still appears on keyboard focus and on devices
89
+ * with no hover at all.
90
+ *
91
+ * The `copied` confirmation deliberately does *not* hold it on screen: once the pointer leaves, the button fades
92
+ * out with the check still on it and resets off-screen, so the icon never flips back to `copy` on a button the
93
+ * user has already walked away from.
94
+ */
95
+ reveal: 'hover' | 'always';
96
+ /**
97
+ * Pins the button to a corner of its containing block, staying put while the source scrolls under it.
98
+ *
99
+ * The bare attribute means `top-right`. The containing block must be positioned (`position: relative` on the
100
+ * element the button sits in), as with any absolutely positioned child.
101
+ */
102
+ pin: CopyButtonPin | '' | undefined;
103
+ /** Set when the last copy attempt failed, so the failure is visible rather than silent. */
104
+ private failed;
105
+ /** Whether the pointer is currently over the source. */
106
+ private hovered;
107
+ /** Whether the source holds *keyboard* focus. Mouse focus does not count — see `handleFocusIn`. */
108
+ private focused;
109
+ private timer;
110
+ /** The element whose hover reveals the button, and whose keyboard focus keeps it revealed. */
111
+ private hoverTarget;
112
+ /** The scroll container whose offset the pin compensates for. */
113
+ private scroller;
114
+ private scrollFrame;
115
+ /**
116
+ * Devices without hover (touch) never fire `pointerenter`, so a hover-only button would be unreachable there.
117
+ * `matchMedia` is read once per instance and kept live, because a hybrid device can gain or lose a pointer.
118
+ */
119
+ private hoverQuery;
120
+ connectedCallback(): void;
121
+ disconnectedCallback(): void;
122
+ firstUpdated(): void;
123
+ willUpdate(changed: PropertyValues<this>): void;
124
+ private handleHoverQueryChange;
125
+ /** True when the button should be visible: `always`, no hover support, or the user is actually there. */
126
+ private get isVisible();
127
+ /**
128
+ * Resolved fade duration of the reveal transition, in ms.
129
+ *
130
+ * Read off the rendered button rather than off the custom property, so an overridden
131
+ * `--c2-copy-button--transition-duration` and `prefers-reduced-motion: reduce` (which drops it to `0s`) are both
132
+ * accounted for without re-deriving the cascade here.
133
+ */
134
+ private get concealDuration();
135
+ private attachHoverTarget;
136
+ private detachHoverTarget;
137
+ private handlePointerEnter;
138
+ private handlePointerLeave;
139
+ /**
140
+ * Only *keyboard* focus holds a hover-reveal button on screen.
141
+ *
142
+ * A mouse click focuses the button too, so counting every `focusin` as a reveal kept an abandoned button fully
143
+ * opaque after the pointer had left — long enough for the confirmation to expire and flip the icon back to
144
+ * `copy` in place, which reads as a phantom second interaction. `:focus-visible` is exactly the distinction the
145
+ * platform already draws, so it is read off the focused element instead of being guessed at.
146
+ */
147
+ private handleFocusIn;
148
+ private handleFocusOut;
149
+ /** Applies a reveal change, re-arming the confirmation timer when the button crosses the visibility line. */
150
+ private updateReveal;
151
+ /**
152
+ * (Re)arms the reset of the `copied` / `failed` state.
153
+ *
154
+ * On screen it runs for the full `copied-duration`. Off screen it only has to outlast the fade: the state is
155
+ * cleared once the button is invisible, so the icon swap is never painted. Coming back mid-confirmation restarts
156
+ * the full countdown, so the state can never stick with no timer behind it.
157
+ */
158
+ private armResolvedTimer;
159
+ private clearResolvedState;
160
+ private attachScroller;
161
+ private detachScroller;
162
+ private handleScroll;
163
+ /**
164
+ * Cancels out the scroll offset of the container.
165
+ *
166
+ * A pinned button is an absolutely positioned child of the scroll container, which puts it in the container's
167
+ * scrollable overflow — so it scrolls away with the content, exactly when the user needs it (long or wide text is
168
+ * the reason there is a scrollbar at all). Translating by the scroll offset holds it against the visible box.
169
+ * The same sign works for all four corners: `right`/`bottom` are resolved against the padding box, whose size is
170
+ * the client size, so every corner needs to move *with* the scroll.
171
+ */
172
+ private syncPinOffset;
173
+ /**
174
+ * The element the text is read from: `for` by id, else the light-DOM parent.
175
+ *
176
+ * `parentElement` is deliberate — a button slotted into another custom element should copy the element it was
177
+ * authored inside, not whatever shadow-DOM node it happens to be rendered in.
178
+ */
179
+ get sourceElement(): HTMLElement | null;
180
+ /** The text that would be copied right now. */
181
+ get text(): string;
182
+ /** Copies `text` to the clipboard and enters the `copied` state. Returns false when the write failed. */
183
+ copy(): Promise<boolean>;
184
+ private handleClick;
185
+ private renderIcon;
186
+ render(): import("lit-html").TemplateResult<1>;
187
+ }
188
+ declare global {
189
+ interface HTMLElementTagNameMap {
190
+ 'c2-copy-button': CopyButton;
191
+ }
192
+ }
193
+ //# sourceMappingURL=copy-button.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"copy-button.d.ts","sourceRoot":"","sources":["../../src/copy-button.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAA4B,KAAK,cAAc,EAAE,MAAM,KAAK,CAAA;AAQ/E,+EAA+E;AAC/E,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,UAAU,GAAG,cAAc,GAAG,aAAa,CAAA;AAIrF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AACH,qBACa,UAAW,SAAQ,UAAU;IACxC,OAAgB,MAAM,0BAAoB;IAE1C,OAAgB,iBAAiB;;;;;;;MAA4D;IAE7F,qFAAqF;IACzE,KAAK,EAAE,MAAM,GAAG,SAAS,CAAY;IAEjD,sHAAsH;IAC1G,GAAG,EAAE,MAAM,GAAG,SAAS,CAAY;IAE/C,yEAAyE;IAC7B,QAAQ,UAAQ;IAE5D,+GAA+G;IACnE,MAAM,UAAQ;IAE1D,gDAAgD;IACU,cAAc,SAAO;IAE/E,gDAAgD;IACpC,KAAK,SAAS;IAE1B,6EAA6E;IACpC,WAAW,SAAW;IAE/D;;;;;;;;OAQG;IAC0B,MAAM,EAAE,OAAO,GAAG,QAAQ,CAAU;IAEjE;;;;;OAKG;IAC0B,GAAG,EAAE,aAAa,GAAG,EAAE,GAAG,SAAS,CAAY;IAE5E,2FAA2F;IAClF,OAAO,CAAC,MAAM,CAAQ;IAE/B,wDAAwD;IAC/C,OAAO,CAAC,OAAO,CAAQ;IAEhC,mGAAmG;IAC1F,OAAO,CAAC,OAAO,CAAQ;IAEhC,OAAO,CAAC,KAAK,CAA2C;IAExD,8FAA8F;IAC9F,OAAO,CAAC,WAAW,CAA2B;IAE9C,iEAAiE;IACjE,OAAO,CAAC,QAAQ,CAA2B;IAE3C,OAAO,CAAC,WAAW,CAAI;IAEvB;;;OAGG;IACH,OAAO,CAAC,UAAU,CAA4B;IAErC,iBAAiB;IAMjB,oBAAoB;IASpB,YAAY;IAKZ,UAAU,CAAC,OAAO,EAAE,cAAc,CAAC,IAAI,CAAC;IAMjD,OAAO,CAAC,sBAAsB,CAA6B;IAE3D,yGAAyG;IACzG,OAAO,KAAK,SAAS,GAIpB;IAED;;;;;;OAMG;IACH,OAAO,KAAK,eAAe,GAO1B;IAMD,OAAO,CAAC,iBAAiB;IAYzB,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,kBAAkB,CAAuD;IACjF,OAAO,CAAC,kBAAkB,CAAwD;IAElF;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa,CAA0G;IAE/H,OAAO,CAAC,cAAc,CAMlB;IAEJ,6GAA6G;IAC7G,OAAO,CAAC,YAAY;IAMpB;;;;;;OAMG;IACH,OAAO,CAAC,gBAAgB;IAMxB,OAAO,CAAC,kBAAkB;IAS1B,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,YAAY,CAGnB;IAED;;;;;;;;OAQG;IACH,OAAO,CAAC,aAAa;IAOrB;;;;;OAKG;IACH,IAAI,aAAa,IAAI,WAAW,GAAG,IAAI,CAMtC;IAED,+CAA+C;IAC/C,IAAI,IAAI,IAAI,MAAM,CAKjB;IAED,yGAAyG;IACnG,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC;IAkB9B,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,UAAU;IAcT,MAAM;CAqBhB;AAqGD,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,qBAAqB;QAC7B,gBAAgB,EAAE,UAAU,CAAA;KAC7B;CACF"}