custom element と shadow DOM で作るコピー機能付きtextarea

Shadow DOM 使用例

Shadow DOM 使用例

custom element と shadow DOM で作るコピー機能付きtextarea

コピー機能付きtextareaを作成する。
custom element と shadow DOM で機能を持ったエレメントを作成する。

特長:

・textareaのサイズ変更にヘッダ部分が追従。
→ MutationObserver()でstyleの変更を監視。

・Closed な Shadow DOM なのにstyle設定可能。
→ Light DOM の style を Shadow DOM にコピー。

・複数の Light DOM に個別に適用。
→ Shadow DOM でクラスのインスタンス生成。

使用結果

ウェブコンポーネントにおける重要な側面の一つが、カプセル化です。マークアップ構造、スタイル、動作を隠蔽し、コード上の他のコードから分離することで、他の部分でクラッシュすることを防ぎ、コードをきれいにしておくことができます。シャドウ DOM API はこの主要部分であり、隠蔽され分離された DOM を要素に取り付けるための方法を提供しています。
使用方法
[HTML]
<style>
textarea-copy.one {
	width: 300px;
	height: 100px;
	border: solid 1px #4ad;
}
textarea-copy.two {
	width: 400px;
	height: 100px;
	border-style: dashed;
	border-width: 0px 2px 2px 2px;
	border-color: #d4a;
	background-color: #fee;
	padding: 5px 10px;
	color: #333;
}
</style>

<textarea-copy class="one" alt="タイトル文字列1"></textarea-copy>

<textarea-copy class="two" alt="タイトル文字列2">ウェブコンポ・・・ます。</textarea-copy>

<script defer src="./js/ce_textarea_copy.js"></script>
		
現時点のコードは以下の通り:
/**
 * コピー機能付きtextarea
 *
 * @author ao-system, Inc.
 * @date 2024-03-02
 *
 * e.g.
 * <textarea-copy alt="タイトル文字列"></textarea-copy>
 * <script defer src="ce_textarea_copy.js"></script>
 *
 */

(() => {
	'use strict';
	class TextareaCopy {
		#constProperties = [
			{	prop: 'svgPath',
				value: '<path d="M2.77,10.45l-1.5,1.5V5.17c0-2.49,2.02-4.51,4.51-4.51h6.78l-1.5,1.5H5.78c-1.66,0-3.01,1.35-3.01,3.01V10.45z"/><path class="icon" d="M12.18,15.34H5.84c-1.3,0-2.35-1.05-2.35-2.35V5.15c0-1.3,1.05-2.35,2.35-2.35h6.34c1.3,0,2.35,1.05,2.35,2.35v7.84C14.53,14.29,13.48,15.34,12.18,15.34z M5.84,4.3c-0.47,0-0.85,0.38-0.85,0.85v7.84c0,0.47,0.38,0.85,0.85,0.85h6.34c0.47,0,0.85-0.38,0.85-0.85V5.15c0-0.47-0.38-0.85-0.85-0.85H5.84z"/>',
			},
			{	prop: 'style',
				value: `
					:host > div { /*wrapper*/
						--color-bar-fore: #fff;
						width: 100%;
						position: relative;
						> div {	/*bar*/
							width: 100%;
							padding: 2px 5px;
							box-sizing: border-box;
							display: flex;
							justify-content: space-between;
							align-items: center;
							column-gap: 5px;
							> div {		/*alt text*/
								font-size: 14px;
								color: var(--color-bar-fore);
								flex-grow: 1;
							}
							> span {	/*sign*/
								font-size: 14px;
								color: var(--color-bar-fore);
							}
							> svg {		/*copy*/
								fill: var(--color-bar-fore);
								opacity: 0.5;
								cursor: pointer;
								&:hover {
									opacity: 1;
								}
							}
						}
						> textarea {
							box-sizing: border-box;
						}
					}
				`,
			},
			{	prop: 'styleElm',
				value: document.createElement('style'),
			},
			{	prop: 'wrapElm',
				value: document.createElement('div'),
			},
			{	prop: 'barElm',
				value: document.createElement('div'),
			},
			{	prop: 'textareaElm',
				value: document.createElement('textarea'),
			},
		];
		constructor() {
			//class内でwritable:falseの定数を作成する方法
			this.#constProperties.forEach((obj) => Object.defineProperty(this, obj.prop, {value:obj.value, writable:false}));
		}
		#clipboardCopy() {
			return new Promise((resolve, reject) => {
				try {
					navigator.clipboard.writeText(this.textareaElm.value).then(() => {
						resolve('Copied!');
					}, () => {
						resolve('Could not copy');
					});
				} catch(e) {
					resolve('HTTPS required');
				}
			});
		}
		//styleを作成
		#styleElement() {
			this.styleElm.textContent = this.style;
		}
		//barを作成
		#barElement(elementRef) {
			const altElm = document.createElement('div');
			altElm.textContent = elementRef.getAttribute('alt') || '';
			this.barElm.appendChild(altElm);
			//
			const signElm = document.createElement('span');
			this.barElm.appendChild(signElm);
			//
			const svgElm = document.createElementNS('http://www.w3.org/2000/svg','svg');
			svgElm.setAttribute('width',16);
			svgElm.setAttribute('height',16);
			svgElm.innerHTML = this.svgPath;
			svgElm.addEventListener('click', async () => {
				signElm.textContent = await this.#clipboardCopy();
				setTimeout(() => { signElm.textContent = ''; },3000);
			});
			this.barElm.appendChild(svgElm);
			this.wrapElm.appendChild(this.barElm);
		}
		//textareaを作成
		#textareaElement(elementRef) {
			this.textareaElm.value = elementRef.innerHTML;
			this.wrapElm.appendChild(this.textareaElm);
		}
		//shadowDOMにappend
		#attachShadow(elementRef,styleElm,wrapElm) {
			const shadowClosed = elementRef.attachShadow({mode:'closed'});
			shadowClosed.innerHTML = '';
			shadowClosed.appendChild(this.styleElm);
			shadowClosed.appendChild(this.wrapElm);
		}
		//textareaのスタイル変更を監視
		#observeTextarea() {
			const observer = new MutationObserver((mutationsList, observer) => {
				for(const mutation of mutationsList) {
					if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
						//textareaのwidthが変化したらwrapperのwidthに適用
						this.wrapElm.style.width = this.textareaElm.style.width;
					}
				}
			});
			observer.observe(this.textareaElm, { attributes:true, attributeFilter:['style'] });
		}
		//textarea-copyのスタイルを取得してshadowDOM内のエレメントに適用する
		#applyStyle(elementRef) {
			const computedStyle = window.getComputedStyle(elementRef);
			for (let i = 0; i < computedStyle.length; i++) {
			    const propertyName = computedStyle[i];
			    const propertyValue = computedStyle.getPropertyValue(propertyName);
				if (['box-sizing','resize','white-space-collapse'].includes(propertyName) == false) {	//これらを除く
				    this.textareaElm.style.setProperty(propertyName, propertyValue);
				}
			}
			//textarea-copyのborderTopColorをshadow内barのbackgroundColorに適用
			this.barElm.style.backgroundColor = computedStyle.borderTopColor;
			//textarea-copyのborderTopLeftRadius,borderTopRightRadiusをbarのborderTopLeftRadius,borderTopRightRadiusに適用
			this.barElm.style.setProperty('border-top-left-radius',computedStyle.borderTopLeftRadius);
			this.barElm.style.setProperty('border-top-right-radius',computedStyle.borderTopRightRadius);
			this.textareaElm.style.borderTopLeftRadius = 0;
			this.textareaElm.style.borderTopRightRadius = 0;
			//textareaのwidthをwrapperのwidthに適用
			this.wrapElm.style.width = this.textareaElm.clientWidth + 'px';
			//textarea-copyのスタイルを削除。<textarea-copy/>を非表示にする為
			elementRef.style.border = 'none';
			elementRef.style.padding = 0;
		}
		render(elementRef) {
			this.#styleElement();
			this.#barElement(elementRef);
			this.#textareaElement(elementRef);
			this.#attachShadow(elementRef);
			this.#observeTextarea();
			this.#applyStyle(elementRef);
		}
	}
	customElements.define('textarea-copy',
		class extends HTMLElement {
			constructor() {
				super();
				(new TextareaCopy()).render(this);
			}
		}
	);
})();
資料
custom element:
https://developer.mozilla.org/ja/docs/Web/API/Web_components/Using_custom_elements
shadow DOM:
https://developer.mozilla.org/ja/docs/Web/API/Web_components/Using_shadow_DOM
この記事は2024年3月当時の物です。
このサイトについてのお問い合わせはエーオーシステムまでお願いいたします。
ご使用上の過失の有無を問わず、本プログラムの運用において発生した損害に対するいかなる請求があったとしても、その責任を負うものではありません。
(2024-03-04)
気付き:
ShadowDOMには「getElementsByTagName」が無い。「getElementById,querySelector」などは有る。
技術メモ:
以下のフッター(メール送信,各種リンク,QRコード生成,ページトップへ移動,copyright)はひとつの宣言型 Shadow DOM (Declarative Shadow DOM) で作成されています。