CodeCode

なんか色々おぼえ書き。だいたいweb制作関連。

コンテナクエリとウェブコンポーネント

コンテナクエリとウェブコンポーネント

今更ながらコンテナクエリとウェブコンポーネントを触ってみたので、その覚書。

コンテナクエリ

コンテナクエリとは親要素のサイズに応じて、子要素のスタイルを定義できるCSSです。 従来、サイズに応じてスタイルを変更するにはメディアクエリを使っていましたが、それはあくまでブラウザのウインドウサイズを基準にしていました。 コンテナクエリでは基準はブラウザのウインドウサイズではなく、親要素のサイズを基準にスタイルを定義できます。

基準がウインドウサイズではなく、親要素のサイズになることでのメリットは、同じ画面内でも要素の表示サイズによってスタイルを変更できます。

例えば、お知らせの一覧が、メインコンテンツとサイドバーにある場合、 コンテンツのスタイルは、別のスタイルを作るか、どちらかを基準に上書きする必要があります。

別のスタイルを作る

.news{
    .news_item{
        /* メインコンテンツのニュース記事スタイル */
    }
}

.sidebar_news{
    .sidebar_news_item{
        /* サイドバーのニュース記事スタイル */
    }
}

上書きする場合

.news{
    .news_item{
        /* メインコンテンツのニュース記事スタイル */
    }
}

.sidebar{
    .news{
        .news_item{
            /* メインコンテンツのニュース記事スタイルを上書き */
        }
    }
}

詳細度の差はあれど、どちらにしても同じコンポーネントなのに、スタイルが分散していました。

コンテナクエリを使うと、.newsのサイズに応じて、スタイルを定義し1箇所にまとめコードの見通しを良くできます。

.news{
    // 要素のインライン方向のサイズを基準にコンテナを定義する
    container-type: inline-size;
    
    .news_item{
        display: grid;
        grid-template-columns: 1fr;
        
        // コンテナの幅が200pxを超える場合
        @container (width > 200px) {
            grid-template-columns: 160px 1fr;
            gap: 20px;
        }
    }
}

余談ですが、コンテナクエリもメディアクエリ同様範囲指定に比較演算子が使えます。

このように表示位置のサイズ(コンテナサイズ)に応じて、スタイルが定義できるのは、ReactやVueのコンポーネントととても相性が良いように思います。 ただ、コンポーネントを使うためだけにライブラリを入れるのはオーバースペックのようにも思います。

ウェブコンポーネント

というので思い出したのが、ウェブコンポーネントです。 がしかし、調べるとオワコンだったり、使い勝手が劣るなどの評価もあり少し悩ましいところですが、web標準のみで実装できるのは魅力なので、今回試してみました。

ウェブコンポーネントとは、再利用可能でカプセル化されたコンポーネントを作成するための技術セットで、ReactやVueなどのライブラリに依存せず、コンポーネントを定義できます。

技術セットは主にカスタムエレメント、シャドウDOM、HTMLテンプレートからなります。

カスタムエレメント

独自のHTMLタグを作成できるようにする仕様。

<news-item></news-item>

シャドウDOM

コンポーネントをカプセル化し、スタイルやスクリプトの干渉を受けないようにする仕組み。

HTMLテンプレート

再利用可能なHTMLマークアップのための仕組み。

<template>
    <p>再利用される要素</p>
</template>

他にHTMLインポートが外部のHTMLファイルをインポートして、DOMに追加する機能があったが、現在廃止されている(ESモジュールなどを使うことが推奨されている)。 そうなってくると、templateをどう使っていいかが悩ましく思います。

また、Slotやアトリビュートを使い、ReactやVueのpropsのように値の受け渡しもできます。

const link = this.getAttribute('link') || '#';

<a href="${link}"><slot name="text"></slot></a>

<news-item link="https://example.com"></news-item>

コンテナクエリとウェブコンポーネントを使ってニュースを作ってみる。

ウェブコンポーネントの定義

まずはウェブコンポーネントを定義する。 components/news-item/news-item.js

import style from "./news-item.css?inline";
import noimage from "./noimg.webp";

class NewsItem extends HTMLElement {
    connectedCallback() {
        // カスタムエレメントの属性を取得
        const link = this.getAttribute('link') || '#';
        const img = this.getAttribute('img') || noimage;
        
        const template = `
            <style>
                ${style} /* CSSをインラインで出力 */
            </style>
            <div class="news_item">
                <a href="${link}">
                    <img src="${img}">
                    <div class="news_contents">
                        <h3><slot name="title"></slot></h3>
                        <p><slot name="excerpt"></slot></p>
                    </div>
                </a>
            </div>
        `;

        const shadow = this.attachShadow({ mode: 'open' });
        shadow.innerHTML = template;
    }
}

customElements.define('news-item', NewsItem);

今回の書き方では、変数templateにテンプレートリテラルでコンポーネントとなるHTMLのテンプレートを記載します。

const template = `
    <style>
        ${style} /* CSSをインラインで出力 */
    </style>
    <div class="news_item">
        <a href="${link}">
            <img src="${img}">
            <div class="news_contents">
                <h3><slot name="title"></slot></h3>
                <p><slot name="excerpt"></slot></p>
            </div>
        </a>
    </div>
`;

このコンポーネントは、カプセル化され良くも悪くも外部からの影響を受けません。 それはCSSも例外ではなく、このコンポーネント内にスタイルを記述しなければ、このテンプレートにスタイルは反映されません。 今回はViteを使い、build時に外部のCSSをインラインで記述します。

CSSのインラインでのインポート

import style from "./news-item.css?inline";

インポートしたCSSを展開

<style>
    ${style} /* CSSをインラインで出力 */
</style>

コンポーネント名はこの記述の通りnews-itemとなります。

customElements.define('news-item', NewsItem);

このJSを読み込んだHTML内では、この名称がタグとして使えます。

<news-item></news-item>

このコンポーネントはSlotとアトリビュートが設定されています。 Slotの表示箇所

<h3><slot name="title"></slot></h3>
<p><slot name="excerpt"></slot></p>

アトリビュートの取得

const link = this.getAttribute('link') || '#';
const img = this.getAttribute('img') || noimage;
<a href="${link}">
    <img src="${img}">

この時のnoimageはアトリビュートが渡ってこなかった場合のフォールバックとしてインポートした画像が設定されています。

import noimage from "./noimg.webp";

Slotとアトリビューの渡し方はそれぞれlinkimgにURLまたはパスを記述します。 SlotはSlotに設定したnameを記述したHTMLタグにそれぞれ渡す内容を記載します。

<news-item link="https://codecodeweb.com" img="./img/news/img01.webp">
    <span slot="title">タイトル1</span>
    <span slot="excerpt">テキストテキストテキストテキスト</span>
</news-item>

(JS側で渡されるデータのチェックをした方がいいのでしょうか、今回は割愛)

<news-item link="https://codecodeweb.com" img="./img/news/img01.webp">
    <span slot="title">タイトル1</span>
    <span slot="excerpt">テキストテキストテキストテキストテキストテキスト</span>
</news-item>

<news-item link="https://hirofuro.org" img="./img/news/img02.webp">
    <span slot="title">タイトル2</span>
    <span slot="excerpt">長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト</span>
</news-item>

<news-item>
    <span slot="title">タイトル3</span>
    <span slot="excerpt">テキストテキストテキストテキストテキストテキスト</span>
</news-item>

コンテナクエリの定義

ウェブコンポーネントを定義して、メインコンテンツとサイドバーに同じコンポーネントを使うことができました。 しかし、このままではどちらも同じスタイルになってしまっているので、コンテナクエリを定義していきます。 今回の場合、サイドバーを基準として、メインコンテンツでは横並びにするので、画像とタイトルの親要素にgridを追加します。

今回のHTML

<div class="news_item">
    <a href="${link}">
        <img src="${img}">
        <div class="news_contents">
            <h3><slot name="title"></slot></h3>
            <p><slot name="excerpt"></slot></p>
        </div>
    </a>
</div>

サイドバーのサイズ200pxを基準にそれよりもコンテナサイズが大きい場合は、横並びになるように設定します。 components/news-item/news-item.css

a{
    display: block;
    text-decoration: none;
    
    @container (width > 200px) {
        display: grid;
        grid-template-columns: 160px 1fr;
        gap: 20px;
    }
}

この時、コンポーネントを内包する親要素にcontainer-typeの記述が必要です。 これはウェブコンポーネントとは別のCSSに記述しています。 css/style.css

.news{
    container-type: inline-size;
}

コンテナサイズ200pxを境にスタイルの表示が変わりました。

ソースコードまとめ

ディレクトリ構造

/
├── components
   └── news-item
       ├── news-item.css
       ├── news-item.js
       └── noimg.webp
├── css
   └── style.css
└── index.html

components/news-item/news-item.js

import style from "./news-item.css?inline";
import noimage from "./noimg.webp";

class NewsItem extends HTMLElement {
    connectedCallback() {
        // カスタムエレメントの属性を取得
        const link = this.getAttribute('link') || '#';
        const img = this.getAttribute('img') || noimage;
        
        const template = `
            <style>
                ${style} /* CSSをインラインで出力 */
            </style>
            <div class="news_item">
                <a href="${link}">
                    <img src="${img}">
                    <div class="news_contents">
                        <h3><slot name="title"></slot></h3>
                        <p><slot name="excerpt"></slot></p>
                    </div>
                </a>
            </div>
        `;

        const shadow = this.attachShadow({ mode: 'open' });
        shadow.innerHTML = template;
    }
}

customElements.define('news-item', NewsItem);

components/news-item/news-item.css

.news_item{
    margin-top: 20px;
    
    & + .news_item{
        margin-top: 30px;
    }
    
    a{
        display: block;
        text-decoration: none;
        
        @container (width > 200px) {
            display: grid;
            grid-template-columns: 160px 1fr;
            gap: 20px;
        }
        
        img{
            width: 100%;
            height: auto;
            aspect-ratio: 4 / 3;
            object-fit: cover;
        }
        
        .news_contents{
            h3{
                font-size: 2rem;
                line-height: 1.4;
                font-weight: bold;
                color: #000;
                margin: 10px 0 0 0;
                
                @container (width > 200px) {
                    margin: 0;
                }
            }
            
            p{
                font-size: 1.6rem;
                line-height: 1.7;
                color: #333;
                margin: 10px 0 0 0;
                
                -webkit-box-orient: vertical;
                -webkit-line-clamp: 2;
                display: -webkit-box;
                overflow: hidden;
                
                @container (width > 200px) {
                    -webkit-line-clamp: 3;
                }
            }
        }
    }
}

css/style.css

.wrap{
    max-width: 980px;
    margin-inline: auto;
    display: grid;
    grid-template-columns: 200px 1fr;
    gap: 5%;
    padding: 60px 0;
    
    .sidebar{
        h2{
            font-size: 1.6rem;
            font-weight: bold;
        }
    }
    
    .main{
        h2{
            font-size: 3rem;
            font-weight: bold;
        }
    }
    
    .news{
        container-type: inline-size;
    }
}

index.html

<head>
    <link rel="stylesheet" href="./css/style.css">
    <script src="./components/news-item/news-item.js" type="module" defer></script>
</head>
<body>
    <div class="wrap">
        
        <aside class="sidebar">
            <div class="news">
                <h2>NEWS</h2>
                
                <news-item link="https://codecodeweb.com" img="./img/news/img01.webp">
                    <span slot="title">タイトル1</span>
                    <span slot="excerpt">テキストテキストテキストテキストテキストテキスト</span>
                </news-item>
                
                <news-item link="https://hirofuro.org" img="./img/news/img02.webp">
                    <span slot="title">タイトル2</span>
                    <span slot="excerpt">長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト</span>
                </news-item>
                
                <news-item>
                    <span slot="title">タイトル3</span>
                    <span slot="excerpt">テキストテキストテキストテキストテキストテキスト</span>
                </news-item>
                
            </div>
        </aside>
        
        <main class="main">
            <div class="news">
                <h2>NEWS</h2>
                
                
                <news-item link="https://codecodeweb.com" img="./img/news/img01.webp">
                    <span slot="title">タイトル1</span>
                    <span slot="excerpt">テキストテキストテキストテキストテキストテキスト</span>
                </news-item>
                
                <news-item link="https://hirofuro.org" img="./img/news/img02.webp">
                    <span slot="title">タイトル2</span>
                    <span slot="excerpt">長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト長いテキスト</span>
                </news-item>
                
                <news-item></news-item>
                
            </div>
        </main>
        
    </div>
</body>
TOPへ戻る