Agent Skills by ALSEL
OpenAIソフトウェア開発⭐ リポ 0品質スコア 70/100

compose-a11y

キーボードナビゲーション、フォーカス管理、ホバー検出、スクリーンリーダーのセマンティクス、コンテキストメニュー、スクロールバー、またはIntelliJプラグインパネル内のSwingフォーカス相互運用が必要なインタラクティブなCompose Desktop UIを構築する際に使用できます。Tab ナビゲーション、フォーカスリング、onPreviewKeyEvent、onKeyEvent、ポインターイベント、セマンティクス、contentDescription、ComposePanel、SwingPanel、またはアクセシビリティレビューで動作します。

description の原文を見る

Use when building interactive Compose Desktop UI that needs keyboard navigation, focus management, hover detection, screen reader semantics, context menus, scrollbars, or Swing focus interop in IntelliJ plugin panels. Triggers on Tab navigation, focus ring, onPreviewKeyEvent, onKeyEvent, pointer events, semantics, contentDescription, ComposePanel, SwingPanel, or accessibility review.

SKILL.md 本文

Compose Desktop キーボードアクセシビリティ

Compose for Desktop UI(Jewel/IntelliJプラグイン)を完全にキーボードアクセス可能にするためのパターン集です。これらのパターンは実際のIntelliJプラグインで広範にテストされており、AndroidのComposeやWebとは異なるCompose Desktopの独特な動作に対応しています。

コア原則

マウスユーザーがアクセスできるすべてのインタラクティブ要素は、キーボードからもアクセス可能で操作可能である必要があります。Tabで進む、Shift+Tabで戻る、Enter/Spaceで実行します。


フォーカスシンク(背景クリック時のフォーカスクリア)

ユーザーが空のスペースをクリックしたとき、テキストフィールドからフォーカスが離れ、最初のフォーカス可能な要素にジャンプしないようにする必要があります。FocusManager.clearFocus(force = true)はCompose Desktopでは機能しません。最初のフォーカス可能な子にリダイレクトされます。

パターン:focusTarget()を使った見えないフォーカスシンク:

val focusSinkRequester = remember { FocusRequester() }

Box(modifier = Modifier.pointerInput(Unit) {
    awaitEachGesture {
        awaitFirstDown(requireUnconsumed = true)  // only background clicks
        focusSinkRequester.requestFocus()
    }
}) {
    Box(Modifier.size(0.dp).focusRequester(focusSinkRequester).focusTarget())
    // ... actual content
}

なぜfocusable()ではなくfocusTarget()か: focusable()bringIntoView動作を含みます。このようなシンクにフォーカスをリクエストするとスムーズスクロールアニメーションがシンクの位置に向かってトリガーされます。シンクがレイアウトの上部にあるため、これは遅い連続的な上向きスクロールドリフトとして現れます。focusTarget()は要素をフォーカスターゲットにしますが、スクロール副作用はありません。

なぜrequireUnconsumed = trueか: falseでは、テキストフィールドやボタンのクリックを含むすべてのポインタイベントが、シンク上でrequestFocus()をトリガーします。これにより不要なフォーカスチャーンが発生し、スクロールドリフトに寄与する可能性があります。trueでは、空の背景スペースのクリックのみがシンクをトリガーします。

シンクはタブでフォーカスチェーンに入るべき:

.onPreviewKeyEvent { event ->
    if (event.type == KeyEventType.KeyDown && event.key == Key.Tab) {
        focusManager.moveFocus(
            if (event.isShiftPressed) FocusDirection.Previous else FocusDirection.Next
        )
        true
    } else false
}

タブナビゲーション

Shift+Tabルール

Key.TabすべてのonPreviewKeyEventハンドラはevent.isShiftPressedを確認する必須です:

.onPreviewKeyEvent { event ->
    if (event.type == KeyEventType.KeyDown && event.key == Key.Tab) {
        focusManager.moveFocus(
            if (event.isShiftPressed) FocusDirection.Previous
            else FocusDirection.Next
        )
        true
    } else false
}

Shiftチェックを忘れるとShift+Tabは何もしなくなります。ユーザーが動けなくなります。

clickable()bringIntoViewをトリガー――スクロールドリフトの罠

clickable()は内部的にfocusable()ノードを作成し、bringIntoView動作を含みます。Tab/Shift+Tabがclickable()を持つ要素にフォーカスを移動するとき、スクロールコンテナはそれを表示するためにスムーズにスクロールします。これにより持続的な遅いスクロールドリフトが発生します。

クリックタブフォーカスの両方が必要な要素の安全なパターン:

.onFocusChanged { isFocused = it.isFocused }
.onPreviewKeyEvent { /* Tab, Enter, Space */ }
.focusTarget()              // our focus target — no bringIntoView
.handOnHover()
.focusProperties { canFocus = false }  // kill clickable's internal focusable
.clickable { onClick() }    // pointer click only, no focus

キー順序のルール:

  1. focusTarget()focusPropertiesの前 ――自分のターゲットはフォーカス可能
  2. focusProperties { canFocus = false }clickableの前 ――clickableの内部focusableをブロック
  3. clickableはポインタイベント処理のみを提供

フォーカス可能な要素で単独の.clickable()を使用しないでください――常に内部のfocusable()bringIntoViewを作成します。代わりに:

  • 上記のfocusTarget + focusProperties + clickableパターンを使うか、
  • canFocus = falseを既に設定しているclickableWithPointer()を使い、その前にfocusTarget()を組み合わせる

インライン編集後のフォーカス復帰

Escapeまたはエンターがインライン編集(タイトルやIDの編集など)を終了するとき、フォーカスはトリガー要素(鉛筆アイコン)に戻る必要があり、失われてはいけません。FocusRequesterを使用します:

val pencilFocusRequester = remember { FocusRequester() }

LaunchedEffect(isEditing) {
    if (isEditing) {
        textFieldFocusRequester.requestFocus()
    } else {
        pencilFocusRequester.requestFocus()  // return focus to trigger
    }
}

これがないと、フォーカスはフォーカスシンクに落ち、その後のタブは最初から始まります。


アイコンボタンをフォーカス可能にする

JewelのIconButtonは内部的にfocusable()を使用し、bringIntoViewスクロールをトリガーします。これを避けながらタブフォーカスをサポートするため、IconButtonfocusTarget()を持つBoxでラップします:

@Composable
fun FocusableIconButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    var isFocused by remember { mutableStateOf(false) }
    val focusBorder = if (isFocused) accentColor else Color.Transparent
    val focusManager = LocalFocusManager.current

    Box(
        modifier = modifier
            .border(1.dp, focusBorder, RoundedCornerShape(4.dp))
            .onFocusChanged { isFocused = it.isFocused }
            .onPreviewKeyEvent { event ->
                if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
                when (event.key) {
                    Key.Enter, Key.NumPadEnter, Key.Spacebar -> { onClick(); true }
                    Key.Tab -> {
                        focusManager.moveFocus(
                            if (event.isShiftPressed) FocusDirection.Previous
                            else FocusDirection.Next
                        )
                        true
                    }
                    else -> false
                }
            }
            .focusTarget(),
    ) {
        IconButton(
            onClick = onClick,
            modifier = Modifier.focusProperties { canFocus = false },  // prevent double focus
        ) { content() }
    }
}

外側のBox(focusTarget())はタブフォーカスを受け取りキーを処理します。内側のIconButtoncanFocus = falseを持つため、2番目のフォーカスターゲットを作成したりbringIntoViewをトリガーしません。


ホバーのみの要素はフォーカス時に表示されている必要がある

ホバーしていないときalpha(0f)で非表示になっている要素(ゴミ箱/削除アイコンなど)は、タブフォーカスを受けたときに表示される必要があります。そうしないとキーボードユーザーが見えない要素にタブで移動します。

var isTrashFocused by remember { mutableStateOf(false) }

FocusableIconButton(
    onClick = onDelete,
    modifier = Modifier
        .onFocusChanged { isTrashFocused = it.hasFocus }
        .alpha(if (isHovered || isTrashFocused) 1f else 0f),
) { /* icon */ }

外側のモディファイアでhasFocusisFocusedではなく)を使用してください。isFocusedはモディファイアノード自身のフォーカスのみを報告しますが、hasFocusは子孫を含みます。実際のfocusTarget()はコンポーネント内部の子孫です。


フォーカスリングパターン

フォーカスインジケータにはテーマのアクセント色を使用します。リングはキーボードフォーカスを持つときのみ表示されるべき:

var isFocused by remember { mutableStateOf(false) }
val focusBorder = if (isFocused) ThemeColors.accent else Color.Transparent

Box(
    modifier = Modifier
        .border(1.dp, focusBorder, RoundedCornerShape(4.dp))
        .onFocusChanged { isFocused = it.isFocused }
        // ... other modifiers
)

フォーカスリングを次に適用します:アイコンボタン、アクションテキストボタン、編集トグルアイコン、タグ追加/削除ボタン、編集可能なID/タイトル鉛筆、およびその他のインタラクティブな非テキストフィールド要素。

テキストフィールドは異なるパターンを使用します。フォーカス時にアクセント色の境界が通常の境界を置き換えます:

val borderColor = when {
    readOnly -> Color.Transparent
    isFocused -> ThemeColors.accent
    else -> ThemeColors.border
}

ポップアップフォーカス管理

キーボード経由でポップアップが開くとき(「+」ボタンのEnter)、内部の入力は自動的にフォーカスを受け取る必要があります:

val inputFocusRequester = remember { FocusRequester() }

LaunchedEffect(Unit) {
    inputFocusRequester.requestFocus()
}

EditableComboBox(
    modifier = Modifier.focusRequester(inputFocusRequester),
    // ...
)

ポップアップはフォーカスが完全に離れるとき閉じるべき:

Box(
    modifier = Modifier
        .onFocusChanged { state ->
            if (state.hasFocus) wasFocused = true
            if (!state.hasFocus && wasFocused) onDismiss()
        }
        .focusTarget(),
) {
    // popup content
}

wasFocusedガードを使用してください。これがないと、onFocusChangedは初期コンポジション時にhasFocus=falseで発火し、入力がフォーカスを受ける前にポップアップが即座に閉じられます。

Jewelコンポーネントはキーイベントをコンシュームする

Jewel EditableComboBox(および潜在的に他のJewel入力コンポーネント)はすべてのキーイベントを内部でコンシュームします――それらの上または上にあるCompose onPreviewKeyEventモディファイアは発火しません。ロギング経由で確認:タイプ時またはEscape押下時に0のonPreviewKeyEventコールバック。

Escapeの回避策: AWTレベルでKeyEventDispatcher経由でキャッチ:

DisposableEffect(Unit) {
    val dispatcher = java.awt.KeyEventDispatcher { event ->
        if (event.id == KeyEvent.KEY_PRESSED && event.keyCode == KeyEvent.VK_ESCAPE) {
            onDismiss()
            true
        } else false
    }
    KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(dispatcher)
    onDispose {
        KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher(dispatcher)
    }
}

これはJewelがイベントを見る前に発火します。onDisposeでのクリーンアップは重大です。これがないと、ディスパッチャーはリークしEscapeをグローバルにキャッチします。


Compose Desktopの落とし穴

clickableでのホバー追跡

MutableInteractionSource + collectIsHoveredAsState()clickable()と組み合わせるとホバーを確実に追跡しません。clickableモディファイアは独自の内部インタラクションソースを作成し、ホバーイベントをインターセプトします。

回避策: あなたのインタラクションソースをclickableに直接渡します:

val hoverSource = remember { MutableInteractionSource() }
val isHovered by hoverSource.collectIsHoveredAsState()

Modifier.clickable(
    interactionSource = hoverSource,
    indication = null,
    onClick = onClick,
)

ComposeハンドラからのFileChooser

FileChooser.chooseFile()をCompose click ハンドラから直接呼び出すことはできません。Compose イベント処理フレームではなく標準 EDT コールフレームが必要なため、アサーション失敗をトリガーします。

// クラッシュ:
onClick = { FileChooser.chooseFile(descriptor, project, null) }

// 機能:
onClick = {
    ApplicationManager.getApplication().invokeLater {
        FileChooser.chooseFile(descriptor, project, null)
    }
}

awaitFirstDownのコンシューム

親のawaitFirstDown(requireUnconsumed = false)は子要素上のクリックを含むすべてのポインタイベントをインターセプトします。空の背景スペースのクリックのみを処理したい場合はrequireUnconsumed = trueを使用します。

focusable()からのスクロールドリフト

focusable()bringIntoView動作を含みます。位置(0,0)にあるfocusable()要素が何度もフォーカスを受け取る場合(例:背景クリックのたびに)、スクロールコンテナはそれに向かってゆっくりアニメーションし、持続的な上向きドリフトを作成します。フォーカスを受け取る必要があるがスクロールをトリガーしない要素にはfocusTarget()を使用します。


スクリーンリーダー用セマンティクス

Compose Multiplatformは意味のあるツリーを提供し、支援技術(macOSではVoiceOver、WindowsではJava Access Bridgeを経由してJAWS/NVDA)は生のUIツリーではなくこのツリーをトラバースします。セマンティクスを追加すると、UIエレメントがスクリーンリーダーに有意になります。

テキスト以外の要素のcontentDescription

アイコン、アイコンボタン、ビジュアルのみの要素はスクリーンリーダーが読み上げられるようにcontentDescriptionが必要:

Icon(
    imageVector = Icons.Default.Delete,
    contentDescription = "Delete step",  // read by screen reader
)

Jewel IconIntelliJIconKeyの場合、contentDescriptionパラメータを直接渡します。

カスタムコンポーネント用semanticsモディファイア

標準のButton/IconButtonを使用していないカスタムインタラクティブ要素を構成するとき、そのロールと説明を明示的に宣言します:

Box(
    modifier = Modifier
        .clickable { onClick() }
        .semantics(mergeDescendants = true) {
            role = Role.Button
            contentDescription = "Click to add attachment"
        }
)

mergeDescendants = trueは子要素のセマンティクスを1つのノードに統合します。スクリーンリーダーは各子を別々に読むのではなく、グループ全体を単一のアイテムとして読み上げます。単一のインタラクティブな概念を表すコンテナ要素(カード、アイコン+テキストを含む行など)に使用します。

トラバーサル順

traversalIndexisTraversalGroupを使用してスクリーンリーダーが要素を読み上げる順序を制御:

Box(
    modifier = Modifier.semantics {
        isTraversalGroup = true
        traversalIndex = -1f  // read before elements with default (0f)
    }
)

低いtraversalIndex値が最初に読まれます。メインコンテンツの前に読まれるべきフローティングアクションボタンまたは優先度の高いコントロールに便利です。

プラットフォームセットアップ

プラットフォームステータスセットアップ
macOSサポート対象箱出しで機能
Windowsサポート対象Java Access Bridgeを有効化:%JAVA_HOME%\bin\jabswitch.exe /enable;ネイティブディストリビューションにjdk.accessibilityモジュールを含める
Linux非サポート

Windows ネイティブディストリビューション向けにbuild.gradle.ktsに追加:

compose.desktop {
    application {
        nativeDistributions {
            modules("jdk.accessibility")
        }
    }
}

セマンティクスプロパティリファレンス

フレームワークはSemanticsNodeプロパティをプラットフォームアクセシビリティAPIにマップします:

SemanticsPropertyAndroid (AccessibilityNodeInfo)iOS (UIAccessibilityElement)Desktop
ContentDescriptiongetContentDescription()accessibilityLabelJava Access Bridge
Text / EditableTextgetText()accessibilityLabel (fallback)
SelectedisSelected()accessibilityTraits.selected
Headingheading roleUIAccessibilityTraitHeader
OnClick actionclickableUIAccessibilityTraitButton

iOS マッピングは遅延評価を使用します。UIAccessibilityElementインスタンスはすべてのノード向けに早期に作成されるのではなく、VoiceOverがツリーをクエリするときに必要に応じて作成されます。

iOS固有:シミュレータテスト

iOS シミュレータで VoiceOver を使用してテストするため、同期アクセシビリティシンクを有効化:

fun MainViewController() = ComposeUIViewController(
    configure = {
        accessibilitySyncOptions = Always()  // sync semantic tree every frame
    }
) { App() }

Always()なしでは、アクセシビリティ更新はシミュレータで遅延または見逃される可能性があります。

iOS固有:ネイティブビュー相互運用

UIKitビュー(UIKitView)を埋め込むとき、アクセシビリティ所有権を制御:

// Composeがアクセシビリティを所有(スクリーンリーダーがcontentDescriptionを読む):
UIKitView(
    factory = { MKMapView() },
    modifier = Modifier.semantics { contentDescription = "Map of NYC" },
    properties = UIKitInteropProperties(isNativeAccessibilityEnabled = false),
)

// ネイティブUIKitがアクセシビリティを所有(スクリーンリーダーがネイティブa11yツリーを読む):
UIKitView(
    factory = { MKMapView() },
    properties = UIKitInteropProperties(isNativeAccessibilityEnabled = true),
)

アクセシビリティテスト

  • macOS: Xcode → Open Developer Tool → Accessibility Inspector
  • Windows: JAWS(Show Speech History)またはNVDA(Speech Viewer)
  • iOS シミュレータ: VoiceOver + accessibilitySyncOptions = Always()
  • Android: Accessibility Scanner アプリ、TalkBack、Espresso アクセシビリティチェック

JUnitでのUIテスト

composeApp/src/desktopTest/kotlin経由の自動アクセシビリティテスト:

// build.gradle.kts
val desktopTest by getting {
    dependencies {
        implementation(compose.desktop.uiTestJUnit4)
        implementation(compose.desktop.currentOs)
    }
}
class AccessibilityTest {
    @get:Rule
    val rule = createComposeRule()

    @Test
    fun `button is focusable and clickable`() {
        rule.setContent {
            MyButton(modifier = Modifier.testTag("myButton"))
        }
        rule.onNodeWithTag("myButton").assertExists()
        rule.onNodeWithTag("myButton").performClick()
    }
}

実行:./gradlew desktopTest


キーボードイベント処理

Compose Desktopはキーボードイベント用に2つのスコープと2つのモディファイアバリアントを提供:

onPreviewKeyEvent vs onKeyEvent

  • onPreviewKeyEvent:デフォルト動作の前に発火します。キーボードショートカットとタブハンドリングに使用します。テキストフィールドが処理する前にイベントをインターセプト・コンシュームできます。
  • onKeyEvent:デフォルト動作の後に発火します。後処理または未処理のキーフォールバックに使用します。

両方ともBooleanを返します。true = コンシュームされた、false = 次のハンドラに渡す。

ウィンドウレベルのキーイベント

フォーカスに関係なく機能するグローバルショートカット向け:

Window(
    onPreviewKeyEvent = {
        if (it.isCtrlPressed && it.key == Key.S && it.type == KeyEventType.KeyDown) {
            save()
            true
        } else false
    }
) { /* content */ }

Window()singleWindowApplication()DialogWindow()で利用可能です。

キーイベントプロパティ

  • event.keyKey.TabKey.EnterKey.EscapeKey.Spacebarなど
  • event.typeKeyEventType.KeyDownKeyEventType.KeyUp
  • event.isCtrlPressedevent.isShiftPressedevent.isAltPressedevent.isMetaPressed

常にevent.type == KeyEventType.KeyDownを確認してください。これがないとハンドラは2回発火します(押下時1回、リリース時1回)。


マウスとポインタイベント

クリックバリアント

// クロスプラットフォーム、安定版――プライマリボタンのみ:
Modifier.combinedClickable(
    onClick = { /* single */ },
    onDoubleClick = { /* double */ },
    onLongClick = { /* long */ },
)

// Desktop専用、実験的――任意のボタン+モディファイア:
@OptIn(ExperimentalFoundationApi::class)
Modifier.onClick(
    matcher = PointerMatcher.mouse(PointerButton.Secondary),
    keyboardModifiers = { isAltPressed },
) { /* right-click + Alt */ }

落とし穴: onClick(実験的)はindicationsemanticsのデフォルトがありません。必要に応じて手動で追加してください。また、Enterキーではトリガーされません――キーボード実行は別途処理する必要があります。

ホバー検出

// 安定版アプローチ――pointerInput ループ:
Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        while (true) {
            val event = awaitPointerEvent()
            // filter synthetic Move events on relayout
        }
    }
}

落とし穴: Compose Desktopは各リレイアウト時に合成Moveイベントを送信します。event.type != PointerEventType.Moveをチェックしてフィルター処理し、enter/exit/pressのみを気にする場合に使用します。

生のAWTイベントアクセス

@OptIn(ExperimentalComposeUiApi::class)
Modifier.onPointerEvent(PointerEventType.Press) {
    val screenLocation = it.awtEventOrNull?.locationOnScreen
}

コンテキストメニュー

任意の要素上のカスタムコンテキストメニュー

ContextMenuArea(items = {
    listOf(
        ContextMenuItem("Delete") { onDelete() },
        ContextMenuItem("Duplicate") { onDuplicate() },
    )
}) {
    // content that gets the context menu on right-click
}

組み込みテキストコンテキストメニュー

  • TextFieldは自動的にカット/コピー/ペースト/すべて選択を取得します
  • Textはコピーサポート向けにSelectionContainer { Text("...") }が必要
  • ContextMenuDataProvider(items = { ... }) { content }経由でカスタムアイテムを追加

Swing相互運用の考慮事項

ComposeがComposePanel内で実行される場合(Jewel/IntelliJ)、これらの制約が適用されます:

ポップアップとダイアログ

すべてのComposeポップアップ、ツールチップ、コンテキストメニューはComposePanel境界内でレンダリングされ、それにクリップされます。IntelliJプラグイン向けに、Composeのポップアップ/ダイアログコンポーザブルよりもコンテンツがオーバーフローする可能性がある場合は、プラットフォームダイアログ(Messages.showDialogDialogWrapper)を推奨します。

SwingPanelレイアリング

SwingPanelは常にComposeコン

ライセンス: Apache-2.0(寛容ライセンスのため全文を引用しています) · 原本リポジトリ

詳細情報

作者
barsia
リポジトリ
barsia/speqa
ライセンス
Apache-2.0
最終更新
2026/4/26

Source: https://github.com/barsia/speqa / ライセンス: Apache-2.0

本サイトは GitHub 上で公開されているオープンソースの SKILL.md ファイルをクロール・インデックス化したものです。 各スキルの著作権は原作者に帰属します。掲載に問題がある場合は info@alsel.co.jp または /takedown フォームよりご連絡ください。
原作者: barsia · barsia/speqa · ライセンス: Apache-2.0