0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CSS 詳細度の計算機を作る — (a,b,c) の数え方と、多くの自作実装が間違える :is() / :where()

0
Posted at

CSS セレクタの詳細度 (specificity) をトークンごとに分解して計算し、複数セレクタを並べて「どれが勝つか」を判定するツールを作った。詳細度は (a, b, c) の 3 つ組で、一見「ID・class・要素を数えるだけ」に見える。だが実装の hinge は :is() / :not() / :has() / :where() の扱いにある。ここを :name をそのまま数える素朴な実装にすると、勝敗予測が普通に外れる。完全ブラウザ完結。

🌐 デモ: https://sen.ltd/portfolio/css-specificity-calculator/
📦 GitHub: https://github.com/sen-ltd/css-specificity-calculator

スクリーンショット

詳細度とは

ある宣言がどのスタイルに勝つかは、まず詳細度で決まる(同点なら最後にソース順)。詳細度は 3 つの数 (a, b, c) で表し、左から順に大小比較する。上位の桁が 1 でも大きければ、下位の桁がいくら大きくても勝つ。

数えるもの
a ID セレクタ #header
b class・属性・擬似クラス .btn [type=x] :hover
c 要素型・擬似要素 div ::before
// (a,b,c) を左から比較。-1/0/1 を返す
export function compare(x, y) {
  for (let i = 0; i < 3; i++) {
    if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
  }
  return 0;
}

1,0,00,99,99 に勝つ。ID 一つは class 99 個より強い。これが「桁を跨いで繰り上がらない」詳細度の本質で、テストで固定しておく:

test("compare is lexicographic", () => {
  assert.equal(compare([0,1,0], [0,0,9]), 1); // class 1 個 > 要素 9 個
  assert.equal(compare([1,0,0], [0,9,9]), 1); // ID 1 個 > その下全部
});

全称セレクタ * と結合子(> + ~ と子孫を表す空白)は詳細度ゼロ。これも忘れがち。

hinge: 関数擬似クラスの重み

ここが本題。:is() :not() :has() の重みは :name 自身ではなく、引数リストの中で最も詳細度の高いセレクタになる。そして :where()常に (0,0,0) で、引数は完全に無料。

const MATCHES_ANY = new Set(["is", "not", "has"]); // 重み = 引数の最大
const ZERO_PSEUDO = new Set(["where"]);            // 重み = 0、引数は無料

function contribution(tok) {
  // ...
  if (tok.type === "pseudo-class") {
    const name = tok.name;
    if (ZERO_PSEUDO.has(name)) return { value: [0,0,0] };       // :where
    if (MATCHES_ANY.has(name)) return { value: maxOfList(tok.args) }; // :is/:not/:has
    // 通常の擬似クラス (:hover 等) は b を 1
    return { value: [0,1,0] };
  }
}

maxOfList は引数のセレクタリストを分割し、各々の詳細度を再帰計算して最大を返すだけ:

function maxOfList(listStr) {
  let best = [0,0,0];
  for (const sel of splitList(listStr)) {
    const { value } = specificityOf(sel);  // 再帰
    if (compare(value, best) > 0) best = value;
  }
  return best;
}

結果はこうなる:

  • :is(#a, p)1,0,0#a と同じ)
  • :where(#a, p)0,0,0(ID が無料になる)
  • a:is(.b)0,1,1a.b と同じ。:is 自体は何も足さない)

テストで全部固定:

test(":is(#a, p) weighs like #a", () => assert.equal(spec(":is(#a, p)"), "1,0,0"));
test(":where(#a, .b, p) → (0,0,0)", () => assert.equal(spec(":where(#a, .b, p)"), "0,0,0"));
test("the :is() token itself adds nothing", () => {
  assert.equal(spec("a:is(.b)"), "0,1,1"); // (0,2,1) ではない
});
test("nested :is keeps taking the max", () => {
  assert.equal(spec(":is(:is(#deep), span)"), "1,0,0");
});

:where() は「リセット CSS やライブラリのデフォルトを、利用者が後から簡単に上書きできるように詳細度を 0 にしておく」ために使う。詳細度計算でここを取りこぼすと、:where で書かれたスタイルの強さを過大評価してしまう。

:nth-child(... of S) の小さな罠

:nth-child() は擬似クラスなので普通は b を 1 つ足す。だが of S 構文を取ると、擬似クラス分の 1 + S の最大詳細度になる:

const NTH_OF = new Set(["nth-child", "nth-last-child"]);
// ...
if (NTH_OF.has(name) && /\bof\b/i.test(tok.args)) {
  const ofPart = tok.args.replace(/^[\s\S]*?\bof\b/i, ""); // "of" 以降
  return { value: add([0,1,0], maxOfList(ofPart)) };
}
test("plain :nth-child is one pseudo-class", () => {
  assert.equal(spec("li:nth-child(2n+1)"), "0,1,1");
});
test("of S adds the selector's specificity", () => {
  assert.equal(spec(":nth-child(2 of .foo)"), "0,2,0"); // 擬似クラス + .foo
});

パースの地味な難所: カンマと括弧

セレクタリストはカンマで区切るが、:is(a, b) の中のカンマで区切ってはいけない。属性値 [data-x="a,b,c"] のカンマも同様。なので「トップレベルのカンマだけ」で分割する必要がある:

export function splitList(selector) {
  const out = [];
  let depthParen = 0, depthBracket = 0, quote = null, start = 0;
  for (let i = 0; i < selector.length; i++) {
    const ch = selector[i];
    if (quote) { if (ch === quote && selector[i-1] !== "\\") quote = null; continue; }
    if (ch === '"' || ch === "'") quote = ch;
    else if (ch === "(") depthParen++;
    else if (ch === ")") depthParen--;
    else if (ch === "[") depthBracket++;
    else if (ch === "]") depthBracket--;
    else if (ch === "," && depthParen === 0 && depthBracket === 0) {
      out.push(selector.slice(start, i).trim());
      start = i + 1;
    }
  }
  const last = selector.slice(start).trim();
  if (last) out.push(last);
  return out.filter(Boolean);
}
test("commas inside :is() stay together", () => {
  assert.deepEqual(splitList(":is(a, b), c"), [":is(a, b)", "c"]);
});
test("commas inside [] do not split", () => {
  assert.equal(analyze('[data-x="a,b,c"]').length, 1);
});

トークナイザ本体も同じ発想で、[...](...) をクオートとネストを尊重してまとめて 1 トークンとして読む。これで a[title="]"](属性値に閉じ括弧)のような意地悪な入力も壊れない。

勝敗の判定

複数セレクタを比較して「最優先」を一意に決める。同点なら CSS ではソース順が効くので、ここでは -1(同点)を返してその旨を表示する:

export function winnerIndex(results) {
  let best = 0, tie = false;
  for (let i = 1; i < results.length; i++) {
    const cmp = compare(results[i].value, results[best].value);
    if (cmp > 0) { best = i; tie = false; }
    else if (cmp === 0) tie = true;
  }
  return tie ? -1 : best;
}

デモでは複数行に貼ったセレクタを並べ、勝者を緑でハイライトし、各セレクタをトークンに分解して「どの桁にいくつ足したか」を色付きチップで見せる。

設計

specificity.js — トークナイザ、トークン別の重み、リスト分割、
                 compare + winnerIndex (DOM-free, 42 tests)
app.js         — textarea → (a,b,c) チップ + トークン分解のライブ表示

specificity.js は DOM 非依存なので 42 個のテストが Node で走る。インラインスタイルや !importantセレクタ詳細度の外側の概念(詳細度の比較より先に効く別レイヤー)なので、意図的に対象外にしている。

試してみる

:is(#x) a:where(#x) a を並べて貼ると、同じ見た目のセレクタが 1,0,10,0,1 に分かれるのが一目で分かる。:where がいかに「詳細度を捨てる」道具かが体感できる。

まとめ

  • 詳細度は (a, b, c)左から比較。上位の桁が勝てば下位は無関係(1,0,0 > 0,99,99)。
  • * と結合子はゼロ
  • hinge: :is() :not() :has()引数の最大詳細度:where()常に 0:name 自身は足さない。
  • :nth-child(... of S) は擬似クラス分の 1 + S の最大。
  • パースはトップレベルのカンマだけで分割し、[...]/(...) はネストとクオートを尊重してまとめ読み。
  • インラインや !important は詳細度の外側なので対象外。

これは SEN 合同会社の OSS ポートフォリオ #273 です。https://sen.ltd/portfolio/

0
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?