Skip to content

Commit 1352acc

Browse files
committed
fix(website): replace the hidden copy hint with a title attribute
The inline sr-only description dragged in a [data-copy-source] wrapper (so _copy would not put the hint on the clipboard) and select-none (so a triple-click would not select it). Three moving parts to deliver one static sentence is bad DX for anyone touching this component later. A title attribute does the same job with none of them: it is the accessible description for the role=button target (the command stays the accessible name), it adds no text content, so selections and _copy see only the command with no wrapper needed, it is a static string, so the render stays byte-stable (the #1127 requirement), and it gives a native hover tooltip for free. The component's net diff against main is now: remove the counter, remove the sr-only span, add one attribute.
1 parent 4302d93 commit 1352acc

2 files changed

Lines changed: 28 additions & 74 deletions

File tree

website/components/copy-cmd.ts

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,19 @@ import { WebComponent, html, signal } from '@webjsdev/core';
55
* affordance. Light DOM, Tailwind utilities throughout. The whole
66
* inner wrapper is the click target (text or icon both trigger copy);
77
* the icon is an always-visible visual hint, not a separate focusable
8-
* element. The command text and a visually-hidden "Copy command to
9-
* clipboard" both sit inside the click target, so the accessible NAME is
10-
* the command followed by the action: a screen reader announces the
11-
* payload and what will happen to it, and no aria-label hides the command.
8+
* element. The command text is the click target's accessible NAME (no
9+
* aria-label hides it), and `title="Copy command to clipboard"` supplies
10+
* the accessible DESCRIPTION, so a screen reader announces the payload and
11+
* the action. The title doubles as a native hover tooltip.
1212
*
13-
* The description is inline rather than an aria-describedby reference on
14-
* purpose. A reference needs a document-unique id, the only way to mint one
15-
* during SSR is a module-scope counter, and a counter never resets in a
16-
* long-lived server, so consecutive renders of the same page emit different
17-
* bytes. That changes the page's ETag on every request and silently kills
18-
* the 304 path site-wide (#1127). Inline text needs no id, so the output is
19-
* byte-stable and the component stays self-contained.
20-
*
21-
* Because the hidden text lives inside the click target, the command gets
22-
* its own [data-copy-source] wrapper and _copy reads THAT, so only the
23-
* command reaches the clipboard. The hidden text is also select-none, so a
24-
* triple-click on the command line and a manual Ctrl+C cannot pick it up
25-
* either (the button path is not the only way a visitor copies a command).
13+
* The description is a title attribute rather than an aria-describedby
14+
* reference on purpose. A reference needs a document-unique id, the only
15+
* way to mint one during SSR is a module-scope counter, and a counter
16+
* never resets in a long-lived server, so consecutive renders of the same
17+
* page emit different bytes. That changes the page's ETag on every request
18+
* and silently kills the 304 path site-wide (#1127). A static attribute
19+
* needs no id, adds no text content (so selections and _copy see only the
20+
* command), and keeps the output byte-stable.
2621
*
2722
* Usage:
2823
* <copy-cmd>npm create webjs@latest my-app</copy-cmd>
@@ -51,10 +46,7 @@ export class CopyCmd extends WebComponent {
5146
}
5247

5348
_copy = async () => {
54-
// Read the command's own wrapper, not the click target: the target also
55-
// carries the visually-hidden description that names the action for a
56-
// screen reader, and that must never land on the clipboard.
57-
const textEl = this.querySelector('[data-copy-source]');
49+
const textEl = this.querySelector('[data-copy-text]');
5850
const text = (textEl?.textContent || '').trim();
5951
if (!text) return;
6052
try {
@@ -102,9 +94,10 @@ export class CopyCmd extends WebComponent {
10294
data-copy-text
10395
role="button"
10496
tabindex="0"
97+
title="Copy command to clipboard"
10598
@click=${this._copy}
10699
@keydown=${this._onKey}
107-
><span data-copy-source><slot></slot></span><span class="sr-only select-none"> Copy command to clipboard</span></span>
100+
><slot></slot></span>
108101
<button
109102
class="absolute right-0 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-7 h-7 p-0 rounded-[7px] border bg-bg-elev cursor-copy opacity-100 transition-[opacity,color,border-color] duration-[140ms] hover:text-fg hover:border-fg-muted ${isCopied ? 'text-[oklch(0.66_0.16_150)] border-accent-tint' : 'text-fg-muted border-border'}"
110103
type="button"

website/test/components/browser/copy-cmd.test.js

Lines changed: 13 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -87,67 +87,28 @@ suite('copy-cmd', () => {
8787
document.body.removeChild(el);
8888
});
8989

90-
test('appends the copy action to the accessible name without hiding the command', async () => {
90+
test('describes the copy action via a title without hiding the command', async () => {
9191
const el = await mount('npm create webjs@latest my-app');
9292
const target = el.querySelector('[data-copy-text]');
9393
// The command stays the accessible NAME (slotted text, no aria-label)...
9494
assert.equal(target.getAttribute('aria-label'), null, 'no aria-label overrides the command name');
9595
assert.ok(target.textContent.includes('npm create webjs@latest my-app'), 'the command is the accessible name');
96-
// ...and a visually-hidden phrase inside the same target appends the copy
97-
// ACTION to the accessible name, so both are announced.
98-
const hint = target.querySelector('.sr-only');
99-
assert.ok(hint, 'a visually-hidden description sits inside the click target');
100-
assert.ok(/copy/i.test(hint.textContent) && /clipboard/i.test(hint.textContent),
101-
'the hidden text describes the copy-to-clipboard action');
102-
assert.ok(target.textContent.includes('Copy command to clipboard'),
103-
'the description is part of the accessible name, after the command');
104-
// Self-contained: the description must not depend on an element rendered
105-
// elsewhere (a layout-provided target would dangle wherever copy-cmd is
106-
// used outside that layout, e.g. an error boundary or another app).
96+
// ...and the title supplies the copy ACTION as the accessible description
97+
// (plus a native hover tooltip). A title needs no id, which is the point:
98+
// the old aria-describedby needed a document-unique id, the only SSR-safe
99+
// source of one is a module-scope counter, and the counter made every
100+
// render emit different bytes, killing the page ETag (#1127).
101+
assert.equal(target.getAttribute('title'), 'Copy command to clipboard',
102+
'the title describes the copy-to-clipboard action');
107103
assert.equal(target.getAttribute('aria-describedby'), null,
108-
'no aria-describedby reference to an element outside the component');
104+
'no id-based description reference remains');
105+
// The description is an attribute, not text, so the target's text is
106+
// EXACTLY the command: selections and _copy cannot pick up anything else.
107+
assert.equal(target.textContent.trim(), 'npm create webjs@latest my-app',
108+
'the click target contains only the command text');
109109
document.body.removeChild(el);
110110
});
111111

112-
test('copying excludes the visually-hidden description', async () => {
113-
// The description shares the click target with the command, so a naive
114-
// textContent read would put "Copy command to clipboard" on the clipboard.
115-
const el = await mount('npm create webjs@latest my-app');
116-
el.querySelector('[data-copy-text]').click();
117-
await tick();
118-
assert.equal(written, 'npm create webjs@latest my-app',
119-
'only the command is copied, not the hidden description');
120-
el.remove();
121-
});
122-
123-
test('the hidden description is excluded from a manual text selection', async () => {
124-
// The copy BUTTON is not the only way a visitor copies a command: plenty
125-
// triple-click the line and hit Ctrl+C. The description sits in the same
126-
// inline container as the command, so it needs user-select:none or it
127-
// pastes along with it.
128-
//
129-
// This asserts the MECHANISM rather than a selection, deliberately. A
130-
// programmatic Range ignores user-select entirely (selectNodeContents will
131-
// happily return the hidden text), so a range-based assertion here would
132-
// fail while real browsers behave correctly, testing nothing useful. The
133-
// actual behaviour was verified against a real browser: a genuine
134-
// triple-click on the hero command yields exactly
135-
// "npm create webjs@latest my-app", and Ctrl+A over the whole document
136-
// does not pick the description up either.
137-
// Asserted as a class rather than a computed style because this harness
138-
// does not load the compiled Tailwind stylesheet, so every utility computes
139-
// to its initial value here. The rest of this file asserts sr-only the same
140-
// way for the same reason.
141-
const el = await mount('npm create webjs@latest my-app');
142-
const hint = el.querySelector('[data-copy-text] .sr-only');
143-
assert.ok(hint.className.includes('select-none'),
144-
'the hidden description opts out of text selection');
145-
const command = el.querySelector('[data-copy-source]');
146-
assert.ok(command, 'the command has its own wrapper so _copy can read it alone');
147-
assert.ok(!command.className.includes('select-none'), 'the command itself stays selectable');
148-
el.remove();
149-
});
150-
151112
test('renders no generated ids, so repeated renders are byte-identical', async () => {
152113
// The regression guard for #1127. Any per-render-varying value in the
153114
// component's output (a counter, a timestamp, a random id) changes the

0 commit comments

Comments
 (0)