Skip to content

Commit fd262c8

Browse files
committed
Add copy button to title on code blocks when there is a single tab
1 parent a9aa27f commit fd262c8

2 files changed

Lines changed: 99 additions & 8 deletions

File tree

‎_TODO.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,6 @@ See comp images in Breadcrumbs component directory
149149

150150
- Need to be able to highlight the title in a code block to copy it, or at least have a "copy" wrapper on it
151151

152-
- The code language is in the tab order and getting focus-visible
153-
154152
## Callout Issues
155153

156154
- When adding backticks in a callout, it gets the standard grey background for a code block in light theme. But it should get an offset of the callout color, like "info-offset".

‎src/components/Code/CodeTabs/client/index.ts‎

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export class CodeTabsElement extends LitElement {
8585
private codeBlocks: HTMLPreElement[] = []
8686
private activeIndex = 0
8787
private copyTimer: number | null = null
88+
private titleCopyTimer: number | null = null
8889
private isInteractiveTabs = false
8990

9091
protected override createRenderRoot(): HTMLElement {
@@ -118,6 +119,10 @@ export class CodeTabsElement extends LitElement {
118119
window.clearTimeout(this.copyTimer)
119120
this.copyTimer = null
120121
}
122+
if (this.titleCopyTimer !== null) {
123+
window.clearTimeout(this.titleCopyTimer)
124+
this.titleCopyTimer = null
125+
}
121126
}
122127

123128
private buildUi(args: { shouldRenderTabs: boolean; labels: string[] }): void {
@@ -129,25 +134,48 @@ export class CodeTabsElement extends LitElement {
129134

130135
if (args.shouldRenderTabs) {
131136
const list = document.createElement('ul')
132-
list.className = 'flex-1 min-w-0 p-0 whitespace-nowrap overflow-auto select-none'
137+
const isSingleTab = !this.isInteractiveTabs
138+
list.className = isSingleTab
139+
? 'flex-1 min-w-0 p-0 whitespace-nowrap overflow-auto'
140+
: 'flex-1 min-w-0 p-0 whitespace-nowrap overflow-auto select-none'
133141

134142
this.tabButtons = this.codeBlocks.map((_, index) => {
135143
const tabLabel = args.labels[index] || `Tab ${index + 1}`
136144

137145
const li = document.createElement('li')
138146
li.className = 'list-none inline-block relative'
139147

148+
/**
149+
* Single-tab blocks render a selectable label with a hover-only copy icon
150+
* instead of an interactive tab button.
151+
*/
152+
if (isSingleTab) {
153+
const wrapper = document.createElement('span')
154+
wrapper.className = 'group inline-flex items-center gap-1 px-2 py-1 m-2'
155+
156+
const label = document.createElement('span')
157+
label.className = 'text-content select-text cursor-text'
158+
label.textContent = tabLabel
159+
160+
const copyIcon = this.createTitleCopyIcon(tabLabel)
161+
162+
wrapper.append(label, copyIcon)
163+
li.append(wrapper)
164+
list.append(li)
165+
166+
/** Placeholder so index alignment with codeBlocks is preserved */
167+
const placeholder = document.createElement('button')
168+
placeholder.type = 'button'
169+
placeholder.hidden = true
170+
return placeholder
171+
}
172+
140173
const button = document.createElement('button')
141174
button.type = 'button'
142175
button.className = 'inline-block px-2 py-1 m-2 text-content-offset'
143176
button.textContent = tabLabel
144177
button.setAttribute('data-code-tabs-button', String(index))
145178

146-
/** Single-tab blocks show a language label that is not interactive */
147-
if (!args.shouldRenderTabs || !this.isInteractiveTabs) {
148-
button.tabIndex = -1
149-
}
150-
151179
addButtonEventListeners(button, () => this.setActive(index), this)
152180

153181
li.append(button)
@@ -208,6 +236,71 @@ export class CodeTabsElement extends LitElement {
208236
return button
209237
}
210238

239+
/**
240+
* Create a small copy icon for the title label in single-tab code blocks.
241+
* The icon is hidden by default and only appears on hover via Tailwind `group-hover`.
242+
* Clicking it copies the title text to the clipboard.
243+
*/
244+
private createTitleCopyIcon(text: string): HTMLButtonElement {
245+
const btn = document.createElement('button')
246+
btn.type = 'button'
247+
btn.tabIndex = -1
248+
btn.className = [
249+
'inline-flex items-center opacity-0 group-hover:opacity-100',
250+
'transition-opacity cursor-pointer',
251+
'text-content hover:text-content-active',
252+
].join(' ')
253+
btn.setAttribute('aria-label', `Copy "${text}"`)
254+
btn.title = `Copy "${text}"`
255+
256+
const copySvg = queryCodeTabsIconMarkup({
257+
iconBankId: ICON_BANK_ID,
258+
iconName: 'copy',
259+
root: document,
260+
}) ?? ''
261+
const checkSvg = queryCodeTabsIconMarkup({
262+
iconBankId: ICON_BANK_ID,
263+
iconName: 'check',
264+
root: document,
265+
}) ?? ''
266+
267+
btn.innerHTML = `
268+
<span data-title-copy-icon="copy">${copySvg}</span>
269+
<span data-title-copy-icon="check" class="hidden">${checkSvg}</span>
270+
`
271+
272+
/** Scale icons down to match the label text size */
273+
btn.querySelectorAll('svg').forEach(svg => {
274+
svg.classList.remove('w-5', 'h-5')
275+
svg.classList.add('w-3.5', 'h-3.5')
276+
})
277+
278+
btn.addEventListener('click', (e) => {
279+
e.stopPropagation()
280+
void this.copyTitleText(btn, text)
281+
})
282+
283+
return btn
284+
}
285+
286+
private async copyTitleText(button: HTMLButtonElement, text: string): Promise<void> {
287+
const copied = await writeToClipboard(text)
288+
if (!copied) return
289+
290+
const copy = button.querySelector('[data-title-copy-icon="copy"]')
291+
const check = button.querySelector('[data-title-copy-icon="check"]')
292+
copy?.classList.add('hidden')
293+
check?.classList.remove('hidden')
294+
295+
if (this.titleCopyTimer !== null) window.clearTimeout(this.titleCopyTimer)
296+
297+
this.titleCopyTimer = window.setTimeout(() => {
298+
copy?.classList.remove('hidden')
299+
check?.classList.add('hidden')
300+
this.titleCopyTimer = null
301+
}, 1500)
302+
}
303+
211304
private setActive(index: number): void {
212305
this.activeIndex = index
213306

0 commit comments

Comments
 (0)