サブメニュー内の無効化アイテムで `cursor: not-allowed` が機能しなかった問題を修正

shoelace-style/webawesome

wa-dropdown-item を無効化した際に適用されるはずの cursor: not-allowed が、サブメニュー内に配置した場合のみ pointer カーソルに上書きされてしまうバグが修正されました。原因は pointer-events: none の適用により、カーソルスタイルの決定が親要素に委ねられていたことです。

背景

<wa-dropdown-item disabled> を通常のドロップダウン内に配置した場合と、サブメニュー内に配置した場合とで、ホバー時のカーソルに不一致が生じていました。通常のドロップダウンでは cursor: default が表示され、サブメニュー内では cursor: pointer が表示されていました。いずれの場合も cursor: not-allowed が表示されることが期待された動作です。

#2276 で報告されたこの問題では、ブラウザの開発者ツールの計算済みスタイルに cursor: not-allowed と表示されているにもかかわらず、実際に表示されるカーソルは pointer になるという奇妙な症状が確認されていました。!important を使用しても解決しなかったとの報告があり、CSSによる上書きが不可能な状態でした。

この現象が起きていたのは、pointer-events: none がブラウザのカーソル解決メカニズムに影響を与えるためです。pointer-events: none が設定された要素は、マウスイベントを「素通り」させます。このとき表示されるカーソルは、自身のスタイルではなく下に位置する要素のスタイルに基づいて決定されます。通常のドロップダウンでは背後の要素が cursor: default を持つため問題になりませんでしたが、サブメニューのポップアップ親要素は cursor: pointer を持っていたため、不一致が生じていました。

技術的な変更

pointer-events: none を適用していた2箇所を削除することで修正されました。変更量は最小限であり、いずれも削除のみです。

1つ目は dropdown-item.styles.ts の CSS ルールです。:host([disabled]) セレクタ内の pointer-events: none; 宣言が削除されました。

変更前:

:host([disabled]) {
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}

変更後:

:host([disabled]) {
  opacity: 0.5;
  cursor: not-allowed;
}

2つ目は dropdown-item.tsupdated() ライフサイクルコールバックです。disabled プロパティが変更された際にインラインスタイルとして pointer-events を書き込んでいた行が削除されました。

変更前:

if (changedProperties.has('disabled')) {
  this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
  this.customStates.set('disabled', this.disabled);
  this.style.pointerEvents = this.disabled ? 'none' : '';
}

変更後:

if (changedProperties.has('disabled')) {
  this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
  this.customStates.set('disabled', this.disabled);
}

クリックイベントのブロックについては、handleHostClick および handleClick のイベントリスナーが既に無効化された項目へのクリック操作を処理しているため、pointer-events: none を除去しても機能上の損失はありません。

設計判断

pointer-events: none を除去してクリック制御をイベントリスナーに一本化するアプローチが採用されました。

pointer-events: none は視覚的フィードバック(カーソル表示)とイベント抑制の両方を同時に担うプロパティです。しかし今回のケースのように、要素のスタック順によってカーソル表示が親要素に依存してしまうと、CSSだけでは上書きできない状態が生まれます。クリック制御を専用のイベントハンドラに委ねることで、pointer-events をカーソルスタイル制御の用途のみに限定し、この干渉を排除しています。

インラインスタイルでの pointer-events 上書きはCSS宣言よりも詳細度が高いため、CSSで cursor: not-allowed を定義していても無効化アイテムへの cursor 制御が CSSだけでは不可能になっていました。updated() からのインラインスタイル削除は、この問題を根本から解消します。

まとめ

pointer-events: none の「カーソルを素通りさせる」副作用が、サブメニュー特有の要素スタック構造と組み合わさってバグを引き起こしていた事例です。クリック制御とカーソル制御の責務を分離し、それぞれを適切な機構(イベントリスナーとCSS)に委ねることで、文脈に依存しない一貫したUI動作が実現されています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
ae8dbf5b

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)、背景、技術的な変更、設計判断、まとめ(結論)からなる「総論→各論→結論」の構成が明確に守られており、非常に分かりやすいです。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト、PR番号・Issue番号のリンク記法がすべて正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

CSSの挙動やWeb Componentsのライフサイクルに関する専門的な内容であり、対象読者であるエンジニアにとって適切です。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論で構成され、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が徹底されています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているコードブロックは、提供されたDiff情報と完全に一致しており、変更点が正確に示されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`pointer-events`、`updated()` ライフサイクルコールバックなど、使用されている技術用語はPR情報と一致しており、文脈上も正確です。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

「pointer-events: none」がカーソル表示に与える影響についての説明は技術的に正確であり、PR Descriptionの内容とも整合性が取れています。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張は、PRのDescriptionやDiff情報によって裏付けられており、ハルシネーション(捏造)は見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#2281)とIssue番号(#2276)が正確に記載・リンクされています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRの主題「correct cursor for disabled items in submenus」を的確に日本語で表現しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事の内容はPR情報に完全に準拠しており、バージョンサポート状況やリリース日程など、PR外の知識の追記はありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「修正されました」「削除されました」など、完了した変更に対する適切な過去形の時制が使用されており、時間表現は正確です。