Skip to content

Instantly share code, notes, and snippets.

@tak-dcxi
Last active August 12, 2025 21:55
Show Gist options
  • Select an option

  • Save tak-dcxi/91c0e143e57a8a482c9ae9e216cba86e to your computer and use it in GitHub Desktop.

Select an option

Save tak-dcxi/91c0e143e57a8a482c9ae9e216cba86e to your computer and use it in GitHub Desktop.
RectObserver
/**
* 要素の位置情報を監視してCSS変数として:rootに設定するWeb Component
* AnchorPositioningのような位置指定に使用可能
*
* @example
* ```html
* <rect-observer var-prefix="global-header">
* <header>...</header>
* </rect-observer>
* ```
*/
export class RectObserverElement extends HTMLElement {
private prefixValue: string | null = null;
private resizeObserver: ResizeObserver | null = null;
private updateScheduled: boolean = false;
/**
* イベントリスナーのオプション
*/
private static readonly PASSIVE_OPTIONS: AddEventListenerOptions = {
passive: true,
capture: false
};
/**
* アクティブなprefixとその要素をMapで管理する
*/
private static activePrefixes: Map<
string,
Set<RectObserverElement>
> = new Map();
/**
* 監視する属性のリスト
*/
static get observedAttributes(): string[] {
return ["var-prefix"];
}
/**
* 要素がDOMに接続されたときの処理
*/
connectedCallback(): void {
if (!this.validatePrefix()) return;
this.registerPrefix();
this.setupObservers();
this.scheduleUpdate();
}
/**
* 要素がDOMから切断されたときの処理
*/
disconnectedCallback(): void {
this.cleanupObservers();
this.unregisterPrefix();
this.clearProperties();
}
/**
* 属性が変更されたときの処理
*/
attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null
): void {
if (name === "var-prefix" && oldValue !== newValue) {
// 古いprefixの登録解除とプロパティクリア
if (oldValue) {
this.unregisterPrefix(oldValue);
this.clearProperties(oldValue);
}
// 新しいprefixを検証
if (!this.validatePrefix()) {
this.cleanupObservers();
return;
}
// 新しいprefixを登録
this.registerPrefix();
// 監視が設定されていない場合は設定
if (!this.resizeObserver) {
this.setupObservers();
}
this.scheduleUpdate();
}
}
/**
* prefix属性の検証
* @returns 有効なprefixが設定されているかどうか
*/
private validatePrefix(): boolean {
const prefix = this.getAttribute("var-prefix");
if (!prefix) {
console.warn("RectObserverElement: var-prefix属性は必須です");
return false;
}
this.prefixValue = prefix;
return true;
}
/**
* prefixの登録と重複チェック
*/
private registerPrefix(): void {
if (!this.prefixValue) return;
// 既存のSetを取得
const existingElements = RectObserverElement.activePrefixes.get(
this.prefixValue
);
// 新規作成が必要な場合
if (!existingElements) {
const newSet = new Set<RectObserverElement>();
newSet.add(this);
RectObserverElement.activePrefixes.set(this.prefixValue, newSet);
return;
}
// 自分以外の要素が既に存在する場合は警告
if (!existingElements.has(this) && existingElements.size > 0) {
console.warn(
`RectObserverElement: var-prefix="${this.prefixValue}"が重複しています。最後に存在する要素の値が適用されます。`
);
}
existingElements.add(this);
}
/**
* prefixの登録解除
*/
private unregisterPrefix(prefix?: string): void {
const targetPrefix = prefix ?? this.prefixValue;
if (!targetPrefix) return;
const elements = RectObserverElement.activePrefixes.get(targetPrefix);
if (elements) {
elements.delete(this);
// Setが空になったらMapからも削除
if (elements.size === 0) {
RectObserverElement.activePrefixes.delete(targetPrefix);
}
}
}
/**
* 監視の設定
*/
private setupObservers(): void {
// ResizeObserverで自身のサイズ変更を監視
this.resizeObserver = new ResizeObserver(() => {
this.scheduleUpdate();
});
this.resizeObserver.observe(this);
// スクロールとウィンドウリサイズを監視
// 静的に定義されたオプションを使用
window.addEventListener(
"scroll",
this.handleViewportChange,
RectObserverElement.PASSIVE_OPTIONS
);
window.addEventListener(
"resize",
this.handleViewportChange,
RectObserverElement.PASSIVE_OPTIONS
);
}
/**
* 監視のクリーンアップ
*/
private cleanupObservers(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// removeEventListenerでも同じオプションを指定
window.removeEventListener(
"scroll",
this.handleViewportChange,
RectObserverElement.PASSIVE_OPTIONS as EventListenerOptions
);
window.removeEventListener(
"resize",
this.handleViewportChange,
RectObserverElement.PASSIVE_OPTIONS as EventListenerOptions
);
}
/**
* ビューポート変更時のハンドラ
* イベントオブジェクトは使用しないため、preventDefaultの呼び出しなし
*/
private handleViewportChange = (): void => {
this.scheduleUpdate();
};
/**
* 更新のスケジューリング
*/
private scheduleUpdate(): void {
if (this.updateScheduled || !this.prefixValue) return;
this.updateScheduled = true;
requestAnimationFrame(() => {
this.updateRect();
this.updateScheduled = false;
});
}
/**
* 位置情報の更新とCSS変数の設定
*/
private updateRect(): void {
if (!this.prefixValue) return;
const rect = this.getBoundingClientRect();
// ビューポート相対位置とサイズ
const properties = {
[`--${this.prefixValue}-rect-left`]: `${rect.left}px`,
[`--${this.prefixValue}-rect-right`]: `${rect.right}px`,
[`--${this.prefixValue}-rect-top`]: `${rect.top}px`,
[`--${this.prefixValue}-rect-bottom`]: `${rect.bottom}px`,
[`--${this.prefixValue}-rect-width`]: `${rect.width}px`,
[`--${this.prefixValue}-rect-height`]: `${rect.height}px`
};
// すべてのプロパティを:rootに設定
Object.entries(properties).forEach(([property, value]) => {
document.documentElement.style.setProperty(property, value);
});
}
/**
* CSS変数のクリア
* @param prefix - クリアするプロパティのprefix(省略時は現在のprefixValueを使用)
*/
private clearProperties(prefix?: string): void {
const targetPrefix = prefix ?? this.prefixValue;
if (!targetPrefix) return;
const propertiesToClear = [
`--${targetPrefix}-rect-left`,
`--${targetPrefix}-rect-right`,
`--${targetPrefix}-rect-top`,
`--${targetPrefix}-rect-bottom`,
`--${targetPrefix}-rect-width`,
`--${targetPrefix}-rect-height`
];
propertiesToClear.forEach((property) => {
document.documentElement.style.removeProperty(property);
});
}
}
if (!customElements.get("rect-observer")) {
customElements.define("rect-observer", RectObserverElement);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment