ブラウザ拡張や企業ポータルの埋め込みUIでは、ホストアプリのデザインシステムを再現するのが難しい。難読化されたクラス名やフレームワーク固有のスタイルを追いかけるより、既存のネイティブ要素をクローンして必要な部分だけ置き換える方が、視覚的一貫性を「無料で」得られる。
コード
interface CloneButtonOptions { /** クローン元のセレクター */ templateSelector: string /** 置き換える新しいテキスト */ text: string /** 置き換える新しいアイコン(省略可) */ icon?: string | HTMLElement /** クリック時のコールバック */ onClick: (event: MouseEvent) => void}
/** * ホストアプリの既存ボタンをクローンして新しいボタンを生成する */export const cloneNativeButton = ( options: CloneButtonOptions): HTMLElement | null => { const { templateSelector, text, icon, onClick } = options
// クローン元を取得 const template = document.querySelector<HTMLElement>(templateSelector) if (!template) { console.warn(`Template not found: ${templateSelector}`) return null }
// 深いクローンを作成(子要素・属性・イベントハンドラーは除く) const cloned = template.cloneNode(true) as HTMLElement
// テキストノードを置き換え const textNode = findTextNode(cloned) if (textNode) { textNode.textContent = text }
// アイコンを置き換え(指定された場合) if (icon) { const iconElement = findIconElement(cloned) if (iconElement) { const newIcon = typeof icon === 'string' ? createIconFromString(icon) : icon iconElement.replaceWith(newIcon) } }
// 元のイベントリスナーは複製されないため、新しいものを設定 cloned.addEventListener('click', onClick)
// IDがあれば削除(重複回避) if (cloned.id) { cloned.removeAttribute('id') }
return cloned}
/** * 要素内の最初のテキストノードを見つける */const findTextNode = (element: HTMLElement): Text | null => { const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { // 空白のみのノードを除外 return node.textContent?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT } } ) return walker.nextNode() as Text | null}
/** * 要素内の最初のアイコン要素を見つける(svg, img, i など) */const findIconElement = (element: HTMLElement): Element | null => { return ( element.querySelector('svg') || element.querySelector('img') || element.querySelector('i[class*="icon"]') || element.querySelector('[role="img"]') )}
/** * HTML文字列からアイコン要素を生成 */const createIconFromString = (html: string): HTMLElement => { const template = document.createElement('template') template.innerHTML = html.trim() return template.content.firstElementChild as HTMLElement}使用例
// GitHub の既存ボタンスタイルを流用するconst myButton = cloneNativeButton({ templateSelector: '.Button--primary', text: 'Deploy', icon: '<svg>...</svg>', onClick: () => { console.log('Deploying...') }})
// DOM に追加document.querySelector('.toolbar')?.appendChild(myButton)仕組み
querySelectorでクローン元のテンプレート要素を取得するcloneNode(true)で深い複製を作成(子要素を含む)TreeWalkerでテキストノードを探索して置き換えるquerySelectorでアイコン要素を探索して置き換える- 元のイベントリスナーは複製されないため
addEventListenerで新しく設定する - ID 属性があれば削除して重複を避ける
cloneNode はスタイル・クラス・data属性を保持するが、イベントリスナーは複製されない点に注意すること。
メリット
- 視覚的一貫性:ホストアプリのデザインシステムを自然に継承できる
- CSS不要:難読化されたクラス名やフレームワークのスタイルを追いかける必要がない
- 保守性が高い:ホスト側のデザイン更新に自動追従する
- 軽量:DOM API のみで実装でき、依存ライブラリ不要
注意点
cloneNode は現在の DOM の状態をコピーするため、動的にクラスやスタイルが変わる要素(ホバー状態など)は複製されない。また、テンプレート要素が削除されると機能しなくなるため、存在確認を入れておくこと。
応用
- ブラウザ拡張で既存UIと統一感のあるボタンを追加する
- 企業ポータルの複雑なデザインシステムへ自然に溶け込む機能を埋め込む
- サードパーティのダッシュボードへカスタム操作パネルを挿入する
実務メモ
このスニペットは、TypeScript、JavaScript、DOM、Browser Extension、UI Pattern の周辺で同じ操作や判定を毎回書きたくない時に向く。小さな補助として切り出しておくと、呼び出し側では意図だけを追いやすい。
逆に、分岐や前提条件が増えて責務が膨らむなら、1本のスニペットに詰め込まない方がよい。手順と helper を分けるか、役割ごとに切り出す方が保守しやすい。
hsb.horse