前回の CSS の値を確認するための Bookmarklet「css-consle」に続いて、カスタムプロパティを一覧で表示する Bookmarklet「custom-props-viewer」を作成しました。

今回作成した Bookmarklet は、以下のリンクをブラウザのブックマークバーにドラッグ & ドロップすることで登録できます。

custom-props-viewer.js

Bookmarklet のコードは、以下の GitHub Gist にもアップしています。

Bookmarklet とは

Bookmarklet(ブックマークレット)は、ウェブブラウザのブックマークから JS のコードを実行する仕組みのことをいい、javascript: スキームを即時関数で実行することによって実現します。

javascript:(() => {
  /* 実行するスクリプト */
})();

例えば、以下は閲覧している記事の titleprompt で表示する Bookmarklet です。

javascript:(() => {
  prompt('title', document.title);
})();

まず、以下の手順で Bookmarklet を登録します。

  1. 適当なウェブページをブックマーク(このページでも可)
  2. ブックマークを編集して、URL を JS のコードに差し替える
  3. ブックマーク名をわかりやすい名前に変更(任意)

試しに、先ほどの Bookmarklet を登録して実行すると、ダイアログが表示されテキストフィールドにページの <title> が表示されます。

Bookmarklet を実行してダイアログが表示されている状態のスクリーンショット
Bookmarklet を Google Chrome で実行した例

デベロッパーツールのコンソールに、JS コード全体をペーストしても実行可能です。

なお、Bookmarklet は JS なので、以下の制約が挙げられます。

  • Content Security Policy(CSP)の指定内容によっては、スクリプトの実行が拒否される
  • ブラウザの拡張機能や、ドキュメントで読み込んでいる JS や CSS と競合する場合がある
  • <iframe> や Shadow DOM の内側には、基本的にはアクセス不可
  • 異なるオリジンのリソースには、基本的にはアクセス不可

また、ブックマークとして登録するときに 1 行のコードになるため、JS を記述するときには以下の点に気をつける必要があります。

  • 行末のセミコロン(;)を省略しない
  • コメントは複数行コメント(/* */)を使用する

Bookmarklet を実行すると、以下のウィジェットが表示されます。

ウィジェットのスクリーンショット。タイトル、Close、フィルタ、カウント、リロード、チェックボックス、セレクタ、プロパティ、値の項目が図で示されている
ウィジェットの構成
項目内容
タイトルBookmarklet の名称
Closeウィジェットを閉じるボタン
フィルタカスタムプロパティの絞り込み
カウントカスタムプロパティの総数
リロードリストを再読み込み
チェックボックスセレクタ(selector)、値(value)の表示・非表示切り替え
セレクタCSS セレクタ
プロパティカスタムプロパティ名
スタイルの値

この Bookmarklet の基本機能としては、ドキュメントに読み込まれているスタイルシート1を解析し、カスタムプロパティの一覧とその値を表示します。

リストの各アイテムには、セレクタ・プロパティ・値のセットで表示されます。

アイテムのスクリーンショット。No.、At-rules(`@layer`、`@media`、`@container`)、セレクタ、プロパティ、値が図で示されている。値には `var()` の展開とカラースウォッチが含まれている
アイテムの構成

@layer@media@container といった At-rules が指定されている場合には、そのグループルールもあわせて表示されます(@supports は割愛しています)。

値に var() が含まれる場合には、var() を展開した値を矢印(➡)の後ろに表示しています。ただし、セレクタで指定した要素がドキュメント内に存在する場合に限られます。

値がカラー(<color>)の場合には、カラースウォッチを表示しています。

「タイトル」の領域をドラッグすることで、ウィジェットを移動できます。

ウィジェットの表示・非表示

見出し「ウィジェットの表示・非表示」

「Close」ボタンを選択するとウィジェットが非表示になり、移動した位置と変更したサイズはリセットされます。ただし、入力した値はページを再読み込みしない限り維持されます。

また、Bookmarklet の JS を実行したときも同様です。実行するごとにウィジェットの表示・非表示が切り替わりますが、値は破棄されずに位置とサイズのみリセットされます。

この機能は、ウィジェットをビューポートの外側まで移動させてしまい、元の位置に戻せないときなどにも活用できます。

テキストフィールドに --<dashed-ident>)に続くプロパティ名を入力することで、カスタムプロパティの絞り込みができます。

一致条件はその文字が含まれるかどうかで、CSS の属性セレクタ([attr*=value])で実現しています。フィルタの具体的な実装内容は後述します。

「リロード」ボタンを選択すると、ドキュメントに読み込まれているスタイルシートを再度解析します。例えば非同期遷移したときやダークモードに切り替えたときに、変更後の値を取得するために使用します。

なお、スタイルシートの変化の有無に関わらず、ボタンを選択するとリストの先頭までスクロールするので、先頭に戻りたいときにも使用できます。

「selector」と「value」のチェックボックスを操作することで、項目の表示・非表示を切り替えることができます。両方のチェックを外すと、カスタムプロパティ名のみを一覧で確認することができます。

基本設定の変更は、前回の「css-console」と同じです。

ウィジェットは Shadow DOM として実装しており、JS の先頭に記述しているクラスフィールドで基本設定を指定しています。

以下にその一部を抜粋しますが、#title は「タイトル」で表示されるテキストで、#inputDebounce はテキストフィールドに入力したときの処理を実行する遅延時間(ms)です。

#styles では CSS のコードをテンプレートリテラルで格納しています。

theme レイヤーで指定しているカスタムプロパティの値を変更して、ウィジェットの配色やフォント指定、基本となるサイズや余白を変更できます。

クラスフィールドの抜粋
class CustomPropsViewer extends HTMLElement {
  /* preferences */
  #title = 'custom-props-viewer';
  #inputDebounce = 600;

  /* CSS */
  #styles = `
    @layer reset, layouts, components, theme;

    @layer theme {
      :host {
        --color-bg-base   : hsl(180 0% 0%);
        --color-bg-widget : hsl(180 10% 20%);
        --color-bg-item   : hsl(180 78% 10%);

        --color-primary   : hsl(180 17% 84%);
        --color-secondary : hsl(180 36% 60%);
        --color-highlight : hsl(180 12% 26%);

        --checkbox-border : hsl(180 7% 30%);
        --checkbox-color  : hsl(46 100% 50%);

        --value-bg        : hsl(180 0% 0%);
        --value-fg        : hsl(180 52% 72%);

        --color-scheme: dark;

        --font-family: Menlo, Consolas, monospace;
        --font-size-m: calc(1rem * 14 / var(--rem, 16));
        --font-size-s: calc(1rem * 12 / var(--rem, 16));
        --line-height: 1.6;
        --letter-spacing: 0.05em;

        --inline-size: min(600px, 100%);
        --padding: 16px;
      }
    }
    /* ... */
  `;
};

ここからは、Bookmarklet の実装内容の一部を紹介します。

カスタムプロパティの取得

見出し「カスタムプロパティの取得」

JS でスタイルシートのカスタムプロパティを取得する方法は、以下の記事を参考にしました。

ドキュメントで読み込んでいるスタイルシートは document.styleSheets で取得できます。

ここから、カスタムプロパティが指定されているスタイルを抽出していくのですが、最終的には以下のようなデータに整形しています。

カスタムプロパティ一覧のデータの例
[
  {
    "prop": "--color-accent",
    "selector": ":root",
    "value": "hsl(180 100% 23%)",
    "layers": [],
    "medias": [],
    "containers": [],
    "calc": ""
  },
  {
    "prop": "--border-color",
    "selector": ":root",
    "value": "var(--color-accent)",
    "layers": [],
    "medias": [],
    "containers": [],
    "calc": "hsl(180 100% 23%)"
  },
  {
    "prop": "--padding",
    "selector": ".foo",
    "value": "40px",
    "layers": [
      "components"
    ],
    "medias": [
      "(prefers-color-scheme: dark)"
    ],
    "containers": [
      "(min-inline-size: 460px)"
    ],
    "calc": ""
  },
  /* ... */
]

このデータをもとに、リストの HTML を生成しています。

フィルタ機能は、おもに CSS の属性セレクタを使用して実装しています。

まず、JS で生成する HTML の各アイテムに、カスタムプロパティ名から --<dashed-ident>)を取り除いてカスタムデータ属性 data-prop として指定しておきます。

生成される HTML の例
<div>
  <label>
    Filter: <input type="search" placeholder="property name">
  </label>
</div>
<ul>
  <li data-prop="color-accent">--color-accent</li>
  <li data-prop="border-color">--border-color</li>
  <li data-prop="padding">--padding</li>
</ul>

あとは、JS で <style> 要素を生成して、入力した値を属性セレクタ([attr*=value])に指定して、マッチしない場合には display: none で非表示にするだけです。

フィルタ JS の例
const search = document.querySelector('input[type="search"]');
const style = document.createElement('style');

document.body?.append(style);

const filterProp = (value, style) => {
  style.textContent = '';

  if (value !== '') {
    /* 属性セレクタ [attr*=value] で表示を制御 */
    const stylesheet = `
      [data-prop]:not([data-prop*="${value}"]) { display: none; }
    `;
    style.append(stylesheet);
  }
};

search?.addEventListener('input', () => {
  filterProp(search.value, style);
});

JS の正規表現を使用して表示を制御してもよかったのですが、今回はシンプルに実装したかったので、CSS の属性セレクタをベースにしました。

前述のように、値に var() が含まれる場合には、var() を展開した値を併記していますが、そのとき、@media に一致するかを window.matchMedia で判定しています。

var() を展開した値を取得する JS の例
const calcStyleValue = (selector, prop, medias) => {
  // メディアクエリに一致するかを判定
  if (medias && !window.matchMedia(medias).matches) return '';

  const target = document.querySelector(selector);

  // 対象となる要素が存在するかを判定
  if (!target) return;

  // 対象となる要素のスタイルの値を返す
  return getComputedStyle(target).getPropertyValue(prop);
};

calcStyleValue('button', '--border-color', '(prefers-color-scheme: dark)');

なお、@containerwindow.matchMedia に相当するメソッドが今のところ存在しない2ので、コンテナクエリに一致するかの判定はしていません。

値がカラー(<color>)の場合にはスウォッチを表示していますが、カラーの判定は以下のように CSS.supports() メソッドを使用しています。

カラーを判定する JS の例
const isColor = (value) => CSS.supports('color', value);

isColor('transparent');       // true
isColor('hsl(180 100% 23%)'); // true
isColor('100%');              // false

Safari における Shadow DOM のバグ

見出し「Safari における Shadow DOM のバグ」

チェックボックスによる表示の制御を :has() 擬似クラスで実装しているときに気づいたのですが、macOS Safari では、Shadow DOM と :has() 擬似クラスのバグが存在するようです。

例えば、以下のコードはチェックボックスの :checked に応じて、.foo.bar の表示・非表示が入れ替わることを想定していますが、Safari ではチェックボックスを変更しても見た目上の変化が起こりません。

Shadow DOM に :has() 擬似クラスを使用したコード
<my-component></my-component>

<script>
class MyComponent extends HTMLElement {
  constructor() {
    super();

    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        /* チェックボックスをオンにしたら非表示 */
        .container:has([type="checkbox"]:checked) .foo {
          display: none;
        }
        /* チェックボックスをオフにしたら非表示 */
        .container:has([type="checkbox"]:not(:checked)) .bar {
          display: none;
        }
      </style>

      <div class="container">
        <label><input type="checkbox">check</label>
        <p class="foo">foo</p>
        <p class="bar">bar</p>
      </div>
    `;
  }
}
customElements.define('my-component', MyComponent);
</script>

ただ、デベロッパーツールで確認すると値自体は反映されているので、描画に反映されていないだけのようです。

macOS Safari のデベロッパーツールでスタイルを表示したときのスクリーンショット
スタイルの値は反映されている

当初は、チェックボックスでの表示の制御を、:has() 擬似クラスを使用した方法で実装していたのですが、このバグが発覚したので別の方法に変更しました。

この現象は、Light DOM でカスタム要素を定義した場合には発生しないため、Shadow DOM 特有の Safari のバグのようです。

前回に引き続き、CSS 関連の Bookmarklet を紹介しました。

Bookmarlet は古くからある手法ですが、特定の目的にあわせたツールを手軽につくりたいといったニーズを満たすことができます。また、使用する場面は限定されるので、CSS や JS の新しい機能を試すことのできる機会にもなると思っています。

今回作成した Bookmarklet に関しても、まだ改善の余地は残されていますが、ひとまず形になったので公開しました。もしお気づきの点がございましたらお知らせください。

脚注

  1. 異なるオリジンのスタイルシートは対象外です。

  2. [css-contain] Similar to window.matchMedia(), Container Queries should have a similar method | Issue #6205 | w3c/csswg-drafts