app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 49478aa11af4f714ca9c3cf27be39a1d776664e6
parent ffa319f1288c7f19eaa3ee8757ae5ccabf3c7a9b
Author: triesap <tyson@radroots.org>
Date:   Wed, 12 Aug 2026 17:20:48 +0000

accessibility: contain modal input and focus

- Track typed return targets across all shell overlays.
- Hide background semantics and suppress modal-external shortcuts.
- Cycle modal focus explicitly and hold busy confirmations.
- Restore focus to actual top-bar, route, and identity triggers.

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt | 54++++++++++++++++++++++++++++++++++++++++++++++--------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt | 5+++--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt | 2++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt | 2++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt | 25+++++++++++++++++++++----
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 119++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellAccessibility.kt | 29++++++++++++-----------------
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellFocusRegistry.kt | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt | 6+++---
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt | 10+++++++++-
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt | 4++--
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
14 files changed, 496 insertions(+), 107 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellOverlays.kt @@ -35,14 +35,37 @@ enum class StatusOverlayKey { Signer, Sync } data class OverlayState( val current: FoundationOverlay? = null, + val returnFocus: ShellFocusTarget? = null, + val restoreFocus: ShellFocusTarget? = null, ) +sealed interface ShellFocusTarget { + data object RouteFallback : ShellFocusTarget + + data object BootstrapFallback : ShellFocusTarget + + data object TodayReference : ShellFocusTarget + + data object TopBarReference : ShellFocusTarget + + data object TopBarSync : ShellFocusTarget + + data object TopBarSigner : ShellFocusTarget + + data class IdentityRow( + val publicKeyHex: String, + ) : ShellFocusTarget +} + sealed interface OverlayIntent { data class Open( val overlay: FoundationOverlay, + val returnFocus: ShellFocusTarget = ShellFocusTarget.RouteFallback, ) : OverlayIntent - data object OpenReference : OverlayIntent + data class OpenReference( + val returnFocus: ShellFocusTarget = ShellFocusTarget.RouteFallback, + ) : OverlayIntent class EditReference( val value: String, @@ -93,9 +116,10 @@ object OverlayReducer { if (intent.overlay is FoundationOverlay.OpenNostrReference) { OverlayTransition(state) } else { - state.withOverlay(intent.overlay) + state.openOverlay(intent.overlay, intent.returnFocus) } - OverlayIntent.OpenReference -> state.withOverlay(FoundationOverlay.OpenNostrReference()) + is OverlayIntent.OpenReference -> + state.openOverlay(FoundationOverlay.OpenNostrReference(), intent.returnFocus) is OverlayIntent.EditReference -> applyReferenceEdit(state, intent.value) OverlayIntent.SubmitReference -> OverlayTransition(state) is OverlayIntent.ApplyReferenceResult -> applyReferenceResult(state, intent.result, intent.clearInput) @@ -105,7 +129,7 @@ object OverlayReducer { if (state.overlays.current is FoundationOverlay.ConfirmAction) { OverlayTransition(state) } else { - state.withOverlay(null) + state.closeOverlay() } is OverlayIntent.Escape -> when (val current = state.overlays.current) { @@ -114,7 +138,7 @@ object OverlayReducer { if (expected == null) OverlayTransition(state) else admitConfirmation(state, expected, submitting = false) } null -> OverlayTransition(state) - else -> state.withOverlay(null) + else -> state.closeOverlay() } } @@ -129,7 +153,7 @@ object OverlayReducer { ReferenceInputAdmission.PrivateKeyShaped -> overlay.copy(input = "", result = ReferenceResult.PrivateKeyRejected) ReferenceInputAdmission.TooLarge -> overlay.copy(input = "", result = ReferenceResult.Invalid) } - return state.withOverlay(updated) + return state.updateOverlay(updated) } private fun admitConfirmation( @@ -168,13 +192,27 @@ object OverlayReducer { clearInput: Boolean, ): OverlayTransition { val overlay = state.overlays.current as? FoundationOverlay.OpenNostrReference ?: return OverlayTransition(state) - return state.withOverlay(overlay.copy(input = if (clearInput) "" else overlay.input, result = result)) + return state.updateOverlay(overlay.copy(input = if (clearInput) "" else overlay.input, result = result)) } - private fun HarvestCircleShellState.withOverlay(overlay: FoundationOverlay?): OverlayTransition = + private fun HarvestCircleShellState.openOverlay( + overlay: FoundationOverlay, + returnFocus: ShellFocusTarget, + ): OverlayTransition = OverlayTransition(copy(overlays = overlays.opened(overlay, returnFocus))) + + private fun HarvestCircleShellState.updateOverlay(overlay: FoundationOverlay): OverlayTransition = OverlayTransition(copy(overlays = overlays.copy(current = overlay))) + + private fun HarvestCircleShellState.closeOverlay(): OverlayTransition = OverlayTransition(copy(overlays = overlays.closed())) } +internal fun OverlayState.opened( + overlay: FoundationOverlay, + returnFocus: ShellFocusTarget, +): OverlayState = copy(current = overlay, returnFocus = returnFocus, restoreFocus = null) + +internal fun OverlayState.closed(): OverlayState = copy(current = null, returnFocus = null, restoreFocus = returnFocus) + enum class ReferenceResult( val message: String, ) { diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt @@ -77,7 +77,7 @@ object ShellReducer { val current = state.overlays.current as? FoundationOverlay.ConfirmAction if (admitted == null) { return if (current?.action is ConfirmationAction.RemoveLocalIdentity) { - state.overlays.copy(current = null) + state.overlays.closed() } else { state.overlays } @@ -104,13 +104,14 @@ object ShellReducer { admitted.signsOut -> "The active session will be signed out." else -> "This saved local identity will be removed." } - return OverlayState( + return state.overlays.opened( FoundationOverlay.ConfirmAction( title = "Remove this saved identity?", explanation = impact, actionLabel = "Remove local identity", action = action, ), + ShellFocusTarget.IdentityRow(admitted.identityId.value), ) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import org.harvestcircle.application.ShellFocusTarget import org.harvestcircle.design.TextSizePreference import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleUiActions @@ -126,6 +127,7 @@ private fun IdentityRow( "Remove ${identity.label}", "remove-identity:${identity.publicKeyHex}", enabled = !model.busy, + modifier = Modifier.shellFocusTarget(ShellFocusTarget.IdentityRow(identity.publicKeyHex)), ) { actions.requestIdentityRemoval(identity.publicKeyHex) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt @@ -1,6 +1,7 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,7 +11,14 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading @@ -43,6 +51,7 @@ fun FoundationOverlayHost( } state.current?.let { overlay -> val overlayBusy = busy || (overlay as? FoundationOverlay.ConfirmAction)?.busy == true + val rootRequester = remember { FocusRequester() } Dialog( onDismissRequest = { if (!overlayBusy) { @@ -57,6 +66,8 @@ fun FoundationOverlayHost( ShellSurface( Modifier .focusGroup() + .focusRequester(rootRequester) + .focusable(overlayBusy && overlay is FoundationOverlay.ConfirmAction) .semantics { contentDescription = "Dialog: ${overlay.title()}" paneTitle = overlay.title() @@ -64,7 +75,7 @@ fun FoundationOverlayHost( .padding(24.dp), ) { when (overlay) { - is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, overlayBusy, onIntent) + is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, overlayBusy, rootRequester, onIntent) is FoundationOverlay.Status -> when (overlay.key) { StatusOverlayKey.Signer -> StatusOverlay("Signer status", status.signer.text, onIntent) @@ -81,9 +92,11 @@ fun FoundationOverlayHost( private fun ConfirmOverlay( overlay: FoundationOverlay.ConfirmAction, busy: Boolean, + rootRequester: FocusRequester, onIntent: (OverlayIntent) -> Unit, ) { - val requester = remember { FocusRequester() } + val confirmRequester = remember { FocusRequester() } + val cancelRequester = remember { FocusRequester() } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { ShellText(overlay.title, Modifier.semantics { heading() }, ShellTextRole.SectionTitle) ShellText(overlay.explanation) @@ -92,7 +105,13 @@ private fun ConfirmOverlay( overlay.actionLabel, overlay.actionLabel, { onIntent(OverlayIntent.Confirm(overlay.action)) }, - Modifier.focusRequester(requester).testTag("overlay-confirm"), + Modifier + .focusRequester(confirmRequester) + .focusProperties { + next = cancelRequester + previous = cancelRequester + }.modalFocusCycle(cancelRequester, cancelRequester) + .testTag("overlay-confirm"), enabled = !busy, kind = ShellButtonKind.Destructive, ) @@ -100,12 +119,20 @@ private fun ConfirmOverlay( "Cancel", "Cancel", { onIntent(OverlayIntent.DismissConfirmation(overlay.action)) }, - Modifier.testTag("overlay-cancel"), + Modifier + .focusRequester(cancelRequester) + .focusProperties { + next = confirmRequester + previous = confirmRequester + }.modalFocusCycle(confirmRequester, confirmRequester) + .testTag("overlay-cancel"), !busy, ) } } - LaunchedEffect(Unit) { requester.requestFocus() } + LaunchedEffect(busy) { + if (busy) rootRequester.requestFocus() else confirmRequester.requestFocus() + } } @Composable @@ -122,7 +149,13 @@ private fun StatusOverlay( "Close", "Close", { onIntent(OverlayIntent.Close) }, - Modifier.focusRequester(requester).testTag("overlay-close"), + Modifier + .focusRequester(requester) + .focusProperties { + next = requester + previous = requester + }.modalFocusCycle(requester, requester) + .testTag("overlay-close"), ) } LaunchedEffect(Unit) { requester.requestFocus() } @@ -134,7 +167,9 @@ private fun ReferenceOverlay( busy: Boolean, onIntent: (OverlayIntent) -> Unit, ) { - val requester = remember { FocusRequester() } + val inputRequester = remember { FocusRequester() } + val submitRequester = remember { FocusRequester() } + val cancelRequester = remember { FocusRequester() } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { ShellText("Open a Nostr reference", Modifier.semantics { heading() }, ShellTextRole.SectionTitle) ShellTextField( @@ -142,7 +177,14 @@ private fun ReferenceOverlay( onValueChange = { onIntent(OverlayIntent.EditReference(it)) }, label = "Nostr link, event ID, or address", placeholder = "nostr:…", - modifier = Modifier.focusRequester(requester).testTag("nostr-reference-input"), + modifier = + Modifier + .focusRequester(inputRequester) + .focusProperties { + next = submitRequester + previous = cancelRequester + }.modalFocusCycle(submitRequester, cancelRequester) + .testTag("nostr-reference-input"), enabled = !busy, ) overlay.result?.let { ShellText(it.message, Modifier.testTag("nostr-reference-result")) } @@ -151,16 +193,47 @@ private fun ReferenceOverlay( "Open a Nostr reference", "Open a Nostr reference", { onIntent(OverlayIntent.SubmitReference) }, - Modifier.testTag("nostr-reference-submit"), + Modifier + .focusRequester(submitRequester) + .focusProperties { + next = cancelRequester + previous = inputRequester + }.modalFocusCycle(cancelRequester, inputRequester) + .testTag("nostr-reference-submit"), enabled = !busy, kind = ShellButtonKind.Primary, ) - ShellButton("Cancel", "Cancel", { onIntent(OverlayIntent.Close) }, Modifier.testTag("overlay-cancel"), !busy) + ShellButton( + "Cancel", + "Cancel", + { onIntent(OverlayIntent.Close) }, + Modifier + .focusRequester(cancelRequester) + .focusProperties { + next = inputRequester + previous = submitRequester + }.modalFocusCycle(inputRequester, submitRequester) + .testTag("overlay-cancel"), + !busy, + ) } } - LaunchedEffect(Unit) { requester.requestFocus() } + LaunchedEffect(Unit) { inputRequester.requestFocus() } } +private fun Modifier.modalFocusCycle( + next: FocusRequester, + previous: FocusRequester, +): Modifier = + onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Tab) { + (if (event.isShiftPressed) previous else next).requestFocus() + true + } else { + false + } + } + private fun FoundationOverlay.title(): String = when (this) { is FoundationOverlay.ConfirmAction -> title diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +import org.harvestcircle.application.ShellFocusTarget data class FoundationTodayModel( val context: String, @@ -37,6 +38,7 @@ fun FoundationTodayScreen( "Open a Nostr reference", "Open a Nostr reference", "today-open-reference", + modifier = Modifier.shellFocusTarget(ShellFocusTarget.TodayReference), onClick = openNostrReference, ) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +import org.harvestcircle.application.ShellFocusTarget import org.harvestcircle.application.SignerStatusLabel import org.harvestcircle.application.SyncStatusLabel @@ -44,11 +45,26 @@ fun GlobalTopBar( ShellAction("Forward", "Go forward", "top-bar-forward", model.canGoForward) { onIntent(GlobalTopBarIntent.Forward) } - ShellAction("Open a Nostr reference", "Open a Nostr reference", "top-bar-open-reference") { + ShellAction( + "Open a Nostr reference", + "Open a Nostr reference", + "top-bar-open-reference", + modifier = Modifier.shellFocusTarget(ShellFocusTarget.TopBarReference), + ) { onIntent(GlobalTopBarIntent.OpenNostrReference) } - ShellAction(model.syncStatus.text, "Sync status", "top-bar-sync") { onIntent(GlobalTopBarIntent.ShowSyncStatus) } - ShellAction(model.signerStatus.text, "Signer status", "top-bar-signer") { + ShellAction( + model.syncStatus.text, + "Sync status", + "top-bar-sync", + modifier = Modifier.shellFocusTarget(ShellFocusTarget.TopBarSync), + ) { onIntent(GlobalTopBarIntent.ShowSyncStatus) } + ShellAction( + model.signerStatus.text, + "Signer status", + "top-bar-signer", + modifier = Modifier.shellFocusTarget(ShellFocusTarget.TopBarSigner), + ) { onIntent(GlobalTopBarIntent.ShowSignerStatus) } ShellAction("Menu", "Application menu", "top-bar-menu") { onIntent(GlobalTopBarIntent.OpenApplicationMenu) } @@ -61,7 +77,8 @@ internal fun ShellAction( description: String, tag: String, enabled: Boolean = true, + modifier: Modifier = Modifier, onClick: () -> Unit, ) { - ShellButton(label, description, onClick, Modifier.testTag(tag), enabled) + ShellButton(label, description, onClick, modifier.testTag(tag), enabled) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt @@ -1,17 +1,24 @@ package org.harvestcircle.ui.shell +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp import org.harvestcircle.application.FoundationOverlay import org.harvestcircle.application.HarvestCircleIntent import org.harvestcircle.application.HarvestCircleShellIntent import org.harvestcircle.application.HarvestCircleShellState import org.harvestcircle.application.OverlayIntent +import org.harvestcircle.application.ShellFocusTarget import org.harvestcircle.application.ShellRoot import org.harvestcircle.application.StatusOverlayKey import org.harvestcircle.application.deriveShellStatus @@ -31,8 +38,14 @@ fun HarvestCircleShell( dispatch: (HarvestCircleShellIntent) -> Unit, ) { HarvestCircleTheme(state.appearance) { - ShellKeyboardHost(onShortcut = { dispatchShortcut(it, dispatch) }) { - HarvestCircleShellContent(state, identityActions, platformActions, dispatch) + val focusRegistry = remember { ShellFocusRegistry() } + CompositionLocalProvider(LocalShellFocusRegistry provides focusRegistry) { + ShellKeyboardHost( + modal = state.overlays.current, + onShortcut = { dispatchShortcut(it, state.overlays.current, dispatch) }, + ) { + HarvestCircleShellContent(state, identityActions, platformActions, dispatch) + } } } } @@ -44,41 +57,54 @@ private fun HarvestCircleShellContent( platformActions: HarvestCirclePlatformActions, dispatch: (HarvestCircleShellIntent) -> Unit, ) { - when (val root = state.root) { - is ShellRoot.LifecycleCanvas -> - ShellLifecycleCanvas(state.identity, identityActions) - is ShellRoot.BootstrapCanvas -> - when (root.step) { - BootstrapStep.Welcome -> BootstrapWelcome(dispatch) - BootstrapStep.CreateIdentity, - BootstrapStep.ImportIdentity, - -> - BootstrapIdentityEntry( - step = root.step, - model = state.identity.toUiModel(), - actions = identityActions, - onBack = { - identityActions.cancelIdentityEntry() - dispatch( - HarvestCircleShellIntent.Navigation( - NavigationIntent.SelectBootstrapStep(BootstrapStep.Welcome), - ), - ) - }, - ) - BootstrapStep.GeneratedRecovery -> - GeneratedRecoveryCanvas(state.identity.toUiModel(), identityActions, platformActions) - BootstrapStep.IdentityChooser, - BootstrapStep.ActivationProgress, - -> - IdentityChooserCanvas( - model = state.identity.toUiModel(), - actions = identityActions, - onReadOnly = { dispatch(HarvestCircleShellIntent.EnterReadOnly) }, - ) - } - is ShellRoot.Dashboard -> DashboardRoot(state, root, identityActions, platformActions, dispatch) + val modalOpen = state.overlays.current != null + val fallback = + if (state.root is ShellRoot.Dashboard) ShellFocusTarget.RouteFallback else ShellFocusTarget.BootstrapFallback + Box( + Modifier + .fillMaxSize() + .shellFocusTarget(ShellFocusTarget.BootstrapFallback) + .focusable(state.root !is ShellRoot.Dashboard) + .then(if (modalOpen) Modifier.clearAndSetSemantics {} else Modifier) + .testTag("shell-background"), + ) { + when (val root = state.root) { + is ShellRoot.LifecycleCanvas -> + ShellLifecycleCanvas(state.identity, identityActions) + is ShellRoot.BootstrapCanvas -> + when (root.step) { + BootstrapStep.Welcome -> BootstrapWelcome(dispatch) + BootstrapStep.CreateIdentity, + BootstrapStep.ImportIdentity, + -> + BootstrapIdentityEntry( + step = root.step, + model = state.identity.toUiModel(), + actions = identityActions, + onBack = { + identityActions.cancelIdentityEntry() + dispatch( + HarvestCircleShellIntent.Navigation( + NavigationIntent.SelectBootstrapStep(BootstrapStep.Welcome), + ), + ) + }, + ) + BootstrapStep.GeneratedRecovery -> + GeneratedRecoveryCanvas(state.identity.toUiModel(), identityActions, platformActions) + BootstrapStep.IdentityChooser, + BootstrapStep.ActivationProgress, + -> + IdentityChooserCanvas( + model = state.identity.toUiModel(), + actions = identityActions, + onReadOnly = { dispatch(HarvestCircleShellIntent.EnterReadOnly) }, + ) + } + is ShellRoot.Dashboard -> DashboardRoot(state, root, identityActions, platformActions, dispatch) + } } + ShellFocusRestorer(state.overlays.restoreFocus, fallback) FoundationOverlayHost(state.overlays, deriveShellStatus(state), state.identity.busy) { dispatch(HarvestCircleShellIntent.Overlay(it)) } @@ -162,7 +188,6 @@ private fun DashboardRoot( RouteFocusTarget( route.toString(), "${route.title()} main content", - restoreFocus = state.overlays.current == null, ) { when (route) { AppRoute.PersonalToday -> @@ -171,7 +196,7 @@ private fun DashboardRoot( openNostrReference = { dispatch( HarvestCircleShellIntent.Overlay( - OverlayIntent.OpenReference, + OverlayIntent.OpenReference(ShellFocusTarget.TodayReference), ), ) }, @@ -211,6 +236,7 @@ private fun DashboardRoot( private fun dispatchShortcut( shortcut: ShellShortcut, + overlay: FoundationOverlay?, dispatch: (HarvestCircleShellIntent) -> Unit, ) { val intent = @@ -218,10 +244,13 @@ private fun dispatchShortcut( ShellShortcut.Back -> HarvestCircleShellIntent.Navigation(NavigationIntent.Back) ShellShortcut.Forward -> HarvestCircleShellIntent.Navigation(NavigationIntent.Forward) ShellShortcut.OpenNostrReference -> - HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference) + HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference()) ShellShortcut.Today -> HarvestCircleShellIntent.Navigate(ScreenKey.PersonalToday) ShellShortcut.Settings -> HarvestCircleShellIntent.Navigate(ScreenKey.Settings) - ShellShortcut.CloseOverlay -> HarvestCircleShellIntent.Overlay(OverlayIntent.Escape()) + ShellShortcut.CloseOverlay -> + HarvestCircleShellIntent.Overlay( + OverlayIntent.Escape((overlay as? FoundationOverlay.ConfirmAction)?.action), + ) } dispatch(intent) } @@ -243,11 +272,15 @@ private fun dispatchTopBar( GlobalTopBarIntent.Back -> HarvestCircleShellIntent.Navigation(NavigationIntent.Back) GlobalTopBarIntent.Forward -> HarvestCircleShellIntent.Navigation(NavigationIntent.Forward) GlobalTopBarIntent.OpenNostrReference -> - HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference) + HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference(ShellFocusTarget.TopBarReference)) GlobalTopBarIntent.ShowSyncStatus -> - HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Sync))) + HarvestCircleShellIntent.Overlay( + OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Sync), ShellFocusTarget.TopBarSync), + ) GlobalTopBarIntent.ShowSignerStatus -> - HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Signer))) + HarvestCircleShellIntent.Overlay( + OverlayIntent.Open(FoundationOverlay.Status(StatusOverlayKey.Signer), ShellFocusTarget.TopBarSigner), + ) GlobalTopBarIntent.OpenApplicationMenu -> HarvestCircleShellIntent.Navigate(ScreenKey.Settings) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellAccessibility.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellAccessibility.kt @@ -4,12 +4,10 @@ import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -25,6 +23,8 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import org.harvestcircle.application.FoundationOverlay +import org.harvestcircle.application.ShellFocusTarget import org.harvestcircle.design.AppearanceState import org.harvestcircle.design.MotionPreference @@ -53,6 +53,7 @@ fun nonessentialMotionEnabled(appearance: AppearanceState): Boolean = appearance @Composable fun ShellKeyboardHost( + modal: FoundationOverlay? = null, onShortcut: (ShellShortcut) -> Unit, content: @Composable () -> Unit, ) { @@ -60,10 +61,9 @@ fun ShellKeyboardHost( Modifier .fillMaxSize() .onPreviewKeyEvent { event -> - event.toShellShortcut()?.let { - onShortcut(it) - true - } ?: false + val shortcut = event.toShellShortcut() ?: return@onPreviewKeyEvent false + if (modal == null || shortcut == ShellShortcut.CloseOverlay) onShortcut(shortcut) + true }.testTag("shell-keyboard-host"), ) { content() @@ -74,11 +74,14 @@ fun ShellKeyboardHost( fun RouteFocusTarget( routeKey: String, label: String, - restoreFocus: Boolean = true, content: @Composable () -> Unit, ) { val requester = remember(routeKey) { FocusRequester() } - var modalWasOpen by remember(routeKey) { mutableStateOf(false) } + val registry = LocalShellFocusRegistry.current + DisposableEffect(registry, requester) { + registry.register(ShellFocusTarget.RouteFallback, requester) + onDispose { registry.unregister(ShellFocusTarget.RouteFallback, requester) } + } Box( Modifier .fillMaxSize() @@ -90,14 +93,6 @@ fun RouteFocusTarget( content() } LaunchedEffect(routeKey) { requester.requestFocus() } - LaunchedEffect(restoreFocus) { - if (!restoreFocus) { - modalWasOpen = true - } else if (modalWasOpen) { - requester.requestFocus() - modalWasOpen = false - } - } } private fun KeyEvent.toShellShortcut(): ShellShortcut? { diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellFocusRegistry.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellFocusRegistry.kt @@ -0,0 +1,55 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import org.harvestcircle.application.ShellFocusTarget + +internal class ShellFocusRegistry { + private val requesters = mutableMapOf<ShellFocusTarget, FocusRequester>() + + fun register( + target: ShellFocusTarget, + requester: FocusRequester, + ) { + requesters[target] = requester + } + + fun unregister( + target: ShellFocusTarget, + requester: FocusRequester, + ) { + if (requesters[target] == requester) requesters.remove(target) + } + + fun request(target: ShellFocusTarget): Boolean = requesters[target]?.requestFocus() == true +} + +internal val LocalShellFocusRegistry = compositionLocalOf { ShellFocusRegistry() } + +@Composable +internal fun Modifier.shellFocusTarget(target: ShellFocusTarget): Modifier { + val registry = LocalShellFocusRegistry.current + val requester = remember(target) { FocusRequester() } + DisposableEffect(registry, target, requester) { + registry.register(target, requester) + onDispose { registry.unregister(target, requester) } + } + return focusRequester(requester) +} + +@Composable +internal fun ShellFocusRestorer( + target: ShellFocusTarget?, + fallback: ShellFocusTarget, +) { + val registry = LocalShellFocusRegistry.current + LaunchedEffect(target, fallback, registry) { + if (target != null && !registry.request(target)) registry.request(fallback) + } +} diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -169,7 +169,7 @@ class HarvestCircleShellPresenterTest { val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) val parser = RecordingReferenceParser(NostrReferenceClassification.Note, "note1canonical") val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this, parser) - presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference)) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference())) presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.EditReference("note1candidate"))) assertTrue(parser.inputs.isEmpty()) presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.SubmitReference)) @@ -189,7 +189,7 @@ class HarvestCircleShellPresenterTest { val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) val parser = RecordingReferenceParser(NostrReferenceClassification.PrivateKeyRejected, null) val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this, parser) - presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference)) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference())) val edit = OverlayIntent.EditReference(privateReference) presenter.dispatch(HarvestCircleShellIntent.Overlay(edit)) @@ -209,7 +209,7 @@ class HarvestCircleShellPresenterTest { val identity = FakeIdentityPresentation(presenterState(HarvestCircleRoute.IDENTITIES)) val parser = RecordingReferenceParser(NostrReferenceClassification.Invalid, null) val presenter = HarvestCircleShellPresenter(identity, BuildInfo.unknown(), this, parser) - presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference)) + presenter.dispatch(HarvestCircleShellIntent.Overlay(OverlayIntent.OpenReference())) val oversized = "x".repeat(2 * 1024 * 1024) presenter.dispatch( HarvestCircleShellIntent.Overlay( diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellOverlaysTest.kt @@ -36,8 +36,16 @@ class ShellOverlaysTest { ).state assertEquals(initial, rejected) - val opened = OverlayReducer.transition(initial, OverlayIntent.OpenReference).state + val opened = + OverlayReducer + .transition(initial, OverlayIntent.OpenReference(ShellFocusTarget.TodayReference)) + .state assertEquals(FoundationOverlay.OpenNostrReference(), opened.overlays.current) + assertEquals(ShellFocusTarget.TodayReference, opened.overlays.returnFocus) + + val closed = OverlayReducer.transition(opened, OverlayIntent.Close).state + assertNull(closed.overlays.current) + assertEquals(ShellFocusTarget.TodayReference, closed.overlays.restoreFocus) } @Test diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt @@ -27,7 +27,7 @@ class ShellReducerTest { ShellEvent.SetTheme(ThemePreference.Dark), ShellEvent.SetTextSize(TextSizePreference.VeryLarge), ShellEvent.SetMotion(MotionPreference.Reduced), - ShellEvent.Overlay(OverlayIntent.OpenReference), + ShellEvent.Overlay(OverlayIntent.OpenReference()), ) val forward = events.fold(initial, ShellReducer::reduce) @@ -67,7 +67,7 @@ class ShellReducerTest { async { presenter.dispatch( HarvestCircleShellIntent.Overlay( - OverlayIntent.OpenReference, + OverlayIntent.OpenReference(), ), ) }, diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHostTest.kt @@ -3,6 +3,7 @@ package org.harvestcircle.ui.shell import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsFocused @@ -13,6 +14,7 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runComposeUiTest import org.harvestcircle.application.BannerSeverity @@ -89,6 +91,27 @@ class FoundationOverlayHostTest { } @Test + fun confirmationFocusWrapsInBothDirections() = + runComposeUiTest { + val state = + OverlayState( + current = + FoundationOverlay.ConfirmAction( + "Remove identity?", + "The local credential will be deleted.", + "Remove local identity", + removalAction(), + ), + ) + setContent { FoundationOverlayHost(state, status(), onIntent = {}) } + + onNodeWithTag("overlay-confirm").assertIsFocused().pressTab() + onNodeWithTag("overlay-cancel").assertIsFocused().pressTab() + onNodeWithTag("overlay-confirm").assertIsFocused().pressShiftTab() + onNodeWithTag("overlay-cancel").assertIsFocused() + } + + @Test fun busyConfirmationBlocksDuplicateSubmissionAndDismissal() = runComposeUiTest { val intents = mutableListOf<OverlayIntent>() @@ -104,12 +127,42 @@ class FoundationOverlayHostTest { ) setContent { FoundationOverlayHost(state, status(), busy = true, onIntent = intents::add) } + onNodeWithTag("foundation-overlay").assertIsFocused() + onNodeWithTag("foundation-overlay").performKeyInput { + keyDown(Key.Escape) + keyUp(Key.Escape) + } onNodeWithTag("overlay-confirm").assertIsNotEnabled().performClick() onNodeWithTag("overlay-cancel").assertIsNotEnabled() + onNodeWithTag("foundation-overlay").assertExists() kotlin.test.assertTrue(intents.isEmpty()) } @Test + fun referenceFocusWrapsAcrossInputAndActions() = + runComposeUiTest { + val state = OverlayState(current = FoundationOverlay.OpenNostrReference()) + setContent { FoundationOverlayHost(state, status(), onIntent = {}) } + + onNodeWithTag("nostr-reference-input").assertIsFocused().pressTab() + onNodeWithTag("nostr-reference-submit").assertIsFocused().pressTab() + onNodeWithTag("overlay-cancel").assertIsFocused().pressTab() + onNodeWithTag("nostr-reference-input").assertIsFocused().pressShiftTab() + onNodeWithTag("overlay-cancel").assertIsFocused() + } + + @Test + fun statusFocusRemainsContainedOnTab() = + runComposeUiTest { + val state = OverlayState(FoundationOverlay.Status(org.harvestcircle.application.StatusOverlayKey.Sync)) + setContent { FoundationOverlayHost(state, status(), onIntent = {}) } + + onNodeWithTag("overlay-close").assertIsFocused().pressTab() + onNodeWithTag("overlay-close").assertIsFocused().pressShiftTab() + onNodeWithTag("overlay-close").assertIsFocused() + } + + @Test fun openStatusDialogRendersTheLatestStatusModel() = runComposeUiTest { var status by mutableStateOf(status()) @@ -122,6 +175,22 @@ class FoundationOverlayHostTest { } } +private fun androidx.compose.ui.test.SemanticsNodeInteraction.pressTab() { + performKeyInput { + keyDown(Key.Tab) + keyUp(Key.Tab) + } +} + +private fun androidx.compose.ui.test.SemanticsNodeInteraction.pressShiftTab() { + performKeyInput { + keyDown(Key.ShiftLeft) + keyDown(Key.Tab) + keyUp(Key.Tab) + keyUp(Key.ShiftLeft) + } +} + private fun status(banner: GlobalStatusBanner? = null) = ShellStatusModel(SyncStatusLabel.NotYetObserved, SignerStatusLabel.SignedOut, banner) diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt @@ -1,17 +1,35 @@ package org.harvestcircle.ui.shell +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.unit.dp +import org.harvestcircle.application.ApplicationLifecycle +import org.harvestcircle.application.ApplicationSnapshot +import org.harvestcircle.application.BuildInfo +import org.harvestcircle.application.FoundationOverlay +import org.harvestcircle.application.HarvestCirclePresenterState +import org.harvestcircle.application.HarvestCircleShellState +import org.harvestcircle.application.OverlayState +import org.harvestcircle.application.SessionLifecycle +import org.harvestcircle.application.ShellFocusTarget +import org.harvestcircle.application.SnapshotRevision +import org.harvestcircle.application.StatusOverlayKey +import org.harvestcircle.identities.ui.HarvestCirclePlatformActions +import org.harvestcircle.identities.ui.HarvestCircleUiActions import kotlin.test.Test @OptIn(ExperimentalTestApi::class) @@ -34,7 +52,7 @@ class ShellAccessibilityUiTest { runComposeUiTest { val shortcuts = mutableListOf<ShellShortcut>() setContent { - ShellKeyboardHost(shortcuts::add) { + ShellKeyboardHost(onShortcut = shortcuts::add) { RouteFocusTarget("today", "Today main content", content = {}) } } @@ -54,31 +72,109 @@ class ShellAccessibilityUiTest { } @Test + fun keyboardHostSuppressesBackgroundShortcutsWhileModalIsOpen() = + runComposeUiTest { + val shortcuts = mutableListOf<ShellShortcut>() + setContent { + ShellKeyboardHost( + modal = FoundationOverlay.Status(StatusOverlayKey.Sync), + onShortcut = shortcuts::add, + ) { + RouteFocusTarget("today", "Today main content", content = {}) + } + } + + onNodeWithTag("route-focus-target").performKeyInput { + keyDown(Key.CtrlLeft) + keyDown(Key.K) + keyUp(Key.K) + keyUp(Key.CtrlLeft) + keyDown(Key.Escape) + keyUp(Key.Escape) + } + kotlin.test.assertEquals(listOf(ShellShortcut.CloseOverlay), shortcuts) + } + + @Test + fun modalRemovesTheBackgroundSemanticsSubtree() = + runComposeUiTest { + setContent { + HarvestCircleShell( + state = modalBootstrapState(), + identityActions = HarvestCircleUiActions(), + platformActions = HarvestCirclePlatformActions(), + dispatch = {}, + ) + } + + onAllNodesWithTag("bootstrap-welcome").assertCountEquals(0) + onAllNodesWithTag("foundation-overlay").assertCountEquals(1) + } + + @Test fun routeTargetRestoresFocusAfterAClosedModal() = runComposeUiTest { var modalOpen by mutableStateOf(false) + var restoreTarget by mutableStateOf<ShellFocusTarget?>(null) setContent { - RouteFocusTarget("today", "Today main content", restoreFocus = !modalOpen) { - ShellAction("Open", "Open dialog", "restore-trigger") { modalOpen = true } - if (modalOpen) { - FoundationOverlayHost( - org.harvestcircle.application.OverlayState( - org.harvestcircle.application.FoundationOverlay.Status( - org.harvestcircle.application.StatusOverlayKey.Sync, + val registry = remember { ShellFocusRegistry() } + CompositionLocalProvider(LocalShellFocusRegistry provides registry) { + RouteFocusTarget("today", "Today main content") { + ShellAction( + "Open", + "Open dialog", + "restore-trigger", + modifier = Modifier.shellFocusTarget(ShellFocusTarget.TodayReference), + ) { + restoreTarget = null + modalOpen = true + } + if (modalOpen) { + FoundationOverlayHost( + org.harvestcircle.application.OverlayState( + org.harvestcircle.application.FoundationOverlay.Status( + org.harvestcircle.application.StatusOverlayKey.Sync, + ), ), - ), - org.harvestcircle.application.ShellStatusModel( - org.harvestcircle.application.SyncStatusLabel.NotYetObserved, - org.harvestcircle.application.SignerStatusLabel.SignedOut, - null, - ), - ) { modalOpen = false } + org.harvestcircle.application.ShellStatusModel( + org.harvestcircle.application.SyncStatusLabel.NotYetObserved, + org.harvestcircle.application.SignerStatusLabel.SignedOut, + null, + ), + ) { + modalOpen = false + restoreTarget = ShellFocusTarget.TodayReference + } + } } + ShellFocusRestorer(restoreTarget, ShellFocusTarget.RouteFallback) } } onNodeWithTag("restore-trigger").performClick() onNodeWithTag("overlay-close").assertIsFocused().performClick() - onNodeWithTag("route-focus-target").assertIsFocused() + onNodeWithTag("restore-trigger").assertIsFocused() } } + +private fun modalBootstrapState(): HarvestCircleShellState = + HarvestCircleShellState( + identity = + HarvestCirclePresenterState( + ApplicationSnapshot( + revision = SnapshotRevision(1UL), + lifecycle = ApplicationLifecycle.Ready, + lifecycleProblem = null, + configuredRelays = emptyList(), + identities = emptyList(), + selectedIdentityId = null, + session = SessionLifecycle.SignedOut, + sessionSubjectIdentityId = null, + sessionProblem = null, + activeIdentity = null, + recoverableProblem = null, + ), + ), + buildInfo = BuildInfo.unknown(), + overlays = OverlayState(current = FoundationOverlay.Status(StatusOverlayKey.Sync)), + )