app

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

commit ffa319f1288c7f19eaa3ee8757ae5ccabf3c7a9b
parent 9e619f94f7db462c008e104cc18b86e289bce276
Author: triesap <tyson@radroots.org>
Date:   Wed, 12 Aug 2026 17:09:11 +0000

accessibility: separate selection from unavailability

- Give shell tabs selectable semantics and keep current choices enabled.
- Make selected route, detail, and appearance activation idempotent.
- Remove selection state from generic button semantics.
- Keep deferred routes disabled, unselected, and explicitly unavailable.

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt | 1-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt | 2--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelHeader.kt | 1-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebar.kt | 2+-
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt | 5+++--
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreenTest.kt | 10+++++-----
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelHeaderTest.kt | 4++--
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt | 11++++++++++-
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebarTest.kt | 16++++++++++++----
10 files changed, 92 insertions(+), 36 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt @@ -100,7 +100,6 @@ fun FoundationNetworkScreen( selected = tab.key == current, onClick = { selected = tab.key }, modifier = Modifier.testTag("network-tab-${tab.key.value}"), - enabled = tab.key != current, ) } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt @@ -58,7 +58,6 @@ fun FoundationSettingsScreen( ) }, modifier = Modifier.testTag("settings-${tab.key.value}"), - enabled = tab.key != current, ) } } @@ -122,7 +121,6 @@ private fun <T : Enum<T>> OptionRow( selected = value == selected, onClick = { select(value) }, modifier = Modifier.testTag("$tagPrefix-${value.name.lowercase()}"), - enabled = value != selected, ) } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelHeader.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelHeader.kt @@ -55,7 +55,6 @@ fun MainPanelHeader( selected = tab.key == model.selectedTab, onClick = { onTabSelected(tab.key) }, modifier = Modifier.testTag("main-tab-${tab.key.value}"), - enabled = tab.key != model.selectedTab, ) } Row(Modifier.testTag("main-secondary-action")) { secondaryAction() } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField @@ -169,15 +170,68 @@ fun ShellButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, - selected: Boolean = false, kind: ShellButtonKind = ShellButtonKind.Secondary, ) { + ShellControl( + label = label, + description = description, + onClick = onClick, + modifier = modifier, + enabled = enabled, + selected = null, + kind = kind, + ) +} + +@Composable +fun ShellTab( + label: String, + description: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + ShellControl( + label = label, + description = description, + onClick = { if (!selected) onClick() }, + modifier = modifier, + enabled = enabled, + selected = selected, + kind = ShellButtonKind.Quiet, + ) +} + +@Composable +private fun ShellControl( + label: String, + description: String, + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + selected: Boolean?, + kind: ShellButtonKind, +) { val interactionSource = remember { MutableInteractionSource() } val hovered by interactionSource.collectIsHoveredAsState() val pressed by interactionSource.collectIsPressedAsState() var focused by remember { mutableStateOf(false) } val palette = LocalHarvestCirclePalette.current - val visuals = resolveShellControlVisuals(kind, enabled, selected, focused, pressed, hovered, palette) + val visuals = resolveShellControlVisuals(kind, enabled, selected == true, focused, pressed, hovered, palette) + val actionModifier = + if (selected == null) { + Modifier.clickable(interactionSource, indication = null, enabled = enabled, role = Role.Button, onClick = onClick) + } else { + Modifier.selectable( + selected = selected, + interactionSource = interactionSource, + indication = null, + enabled = enabled, + role = Role.Tab, + onClick = onClick, + ) + } Box( modifier .heightIn(min = HarvestCircleDesign.MINIMUM_TARGET_DP.dp) @@ -188,12 +242,12 @@ fun ShellButton( RoundedCornerShape(LocalHarvestCircleShapes.current.controlRadiusDp.dp), ).onFocusChanged { focused = it.isFocused } .hoverable(interactionSource, enabled) - .clickable(interactionSource, indication = null, enabled = enabled, role = Role.Button, onClick = onClick) + .then(actionModifier) .focusable(enabled, interactionSource) .semantics { contentDescription = description - role = Role.Button - this.selected = selected + role = if (selected == null) Role.Button else Role.Tab + selected?.let { this.selected = it } this[ShellControlBackgroundKey] = visuals.background.semanticValue() this[ShellControlForegroundKey] = visuals.foreground.hex this[ShellControlBorderKey] = visuals.border.hex @@ -235,18 +289,6 @@ fun ShellIconButton( } @Composable -fun ShellTab( - label: String, - description: String, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, -) { - ShellButton(label, description, onClick, modifier, enabled, selected, ShellButtonKind.Quiet) -} - -@Composable fun ShellTextField( value: String, onValueChange: (String) -> Unit, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebar.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebar.kt @@ -50,7 +50,7 @@ private fun SidebarItem( selected = selected, onClick = { onScreen(item.screenKey) }, modifier = Modifier.fillMaxWidth().testTag("sidebar-${item.screenKey.name}"), - enabled = item.enabled && !selected, + enabled = item.enabled, ) } diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag @@ -28,7 +28,8 @@ class FoundationNetworkScreenTest { runComposeUiTest { setContent { FoundationNetworkScreen(model()) } onNodeWithTag("bounded-detail-network").assertExists() - onNodeWithTag("network-tab-overview").assertIsSelected().assertIsNotEnabled() + onNodeWithTag("network-tab-overview").assertIsSelected().assertIsEnabled().performClick() + onNodeWithTag("network-tab-overview").assertIsSelected() onNodeWithText("Signer").assertExists() onNodeWithText("Local identity active").assertExists() onNodeWithText("No managed HarvestCircle service is configured.").assertExists() diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreenTest.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onAllNodesWithText @@ -46,10 +46,10 @@ class FoundationSettingsScreenTest { } onNodeWithTag("bounded-detail-settings").assertExists() - onNodeWithTag("settings-appearance").assertIsSelected().assertIsNotEnabled() - onNodeWithTag("theme-system").assertIsSelected().assertIsNotEnabled() - onNodeWithTag("text-size-default").assertIsSelected().assertIsNotEnabled() - onNodeWithTag("motion-standard").assertIsSelected().assertIsNotEnabled() + onNodeWithTag("settings-appearance").assertIsSelected().assertIsEnabled().performClick() + onNodeWithTag("theme-system").assertIsSelected().assertIsEnabled().performClick() + onNodeWithTag("text-size-default").assertIsSelected().assertIsEnabled().performClick() + onNodeWithTag("motion-standard").assertIsSelected().assertIsEnabled().performClick() onNodeWithTag("theme-dark").performClick() onNodeWithTag("text-size-verylarge").performClick() onNodeWithTag("motion-reduced").performClick() diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelHeaderTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelHeaderTest.kt @@ -3,7 +3,7 @@ package org.harvestcircle.ui.shell import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag @@ -35,7 +35,7 @@ class MainPanelHeaderTest { } onNodeWithText("Network").assertIsDisplayed() onNodeWithTag("main-breadcrumb").assertIsDisplayed() - onNodeWithTag("main-tab-overview").assertIsSelected().assertIsNotEnabled() + onNodeWithTag("main-tab-overview").assertIsSelected().assertIsEnabled().performClick() onNodeWithTag("main-tab-identity").performClick() onAllNodesWithTag("main-primary-action").assertCountEquals(1) assertEquals(listOf(identity.key), selections) diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt @@ -3,10 +3,13 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.layout.Column import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertTextContains @@ -17,6 +20,7 @@ import org.harvestcircle.design.AppearanceState import org.harvestcircle.design.HarvestCircleDesign import org.harvestcircle.design.ThemePreference import kotlin.test.Test +import kotlin.test.assertFalse @OptIn(ExperimentalTestApi::class) class ShellControlsUiTest { @@ -38,8 +42,13 @@ class ShellControlsUiTest { } } - onNodeWithTag("control-tab").assertIsSelected().assertHeightIsAtLeast(44.dp) + onNodeWithTag("control-tab") + .assertIsSelected() + .assertIsEnabled() + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Tab)) + .assertHeightIsAtLeast(44.dp) onNodeWithTag("control-disabled").assertIsNotEnabled() + assertFalse(onNodeWithTag("control-disabled").fetchSemanticsNode().config.contains(SemanticsProperties.Selected)) onNodeWithTag("control-field").assertTextContains("npub1…") onNodeWithTag("control-icon").assertHeightIsAtLeast(44.dp) } diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebarTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/WorkspaceSidebarTest.kt @@ -1,7 +1,10 @@ package org.harvestcircle.ui.shell +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag @@ -11,6 +14,7 @@ import androidx.compose.ui.test.v2.runComposeUiTest import org.harvestcircle.product.ScreenKey import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue @OptIn(ExperimentalTestApi::class) @@ -20,12 +24,16 @@ class WorkspaceSidebarTest { runComposeUiTest { val selected = mutableListOf<ScreenKey>() setContent { WorkspaceSidebar(ScreenKey.PersonalToday, selected::add) } - onNodeWithTag("sidebar-PersonalToday").assertIsSelected() - onNodeWithTag("sidebar-PersonalToday").assertIsNotEnabled() - onNodeWithText("Explore").assertIsNotEnabled() - onNodeWithText("Activity").assertIsNotEnabled() + onNodeWithTag("sidebar-PersonalToday").assertIsSelected().assertIsEnabled().performClick() + onNodeWithText("Explore").assertIsNotEnabled().assertIsNotSelected() + onNodeWithText("Activity").assertIsNotEnabled().assertIsNotSelected() onNodeWithText("Add a farm workspace").assertIsNotEnabled() onNodeWithContentDescription("Explore. Not available in this build.").assertExists() + onNodeWithContentDescription("Activity. Not available in this build.").assertExists() + onNodeWithContentDescription("Add a farm workspace. Not available in this build.").assertExists() + assertFalse( + onNodeWithTag("sidebar-add-farm").fetchSemanticsNode().config.contains(SemanticsProperties.Selected), + ) onNodeWithText("Explore").performClick() onNodeWithText("Network").performClick() onNodeWithText("Settings").performClick()