@@ -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