From 6e5a47ed5c76bddd910f3bc5205e2970609fa328 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 21 Sep 2026 12:20:27 -0500 Subject: [PATCH] Components: app-wide keyboard focus rings plus workspace primitives (icon button, nav row, tag chip, status dot, banner, empty state) --- .../kotlin/com/shonar/desktop/Components.kt | 239 +++++++++++++++++- 1 file changed, 233 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/shonar/desktop/Components.kt b/app/src/main/kotlin/com/shonar/desktop/Components.kt index 31da4a3..321cb2b 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Components.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Components.kt @@ -1,6 +1,13 @@ package com.shonar.desktop import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -29,9 +36,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -41,8 +52,30 @@ import androidx.compose.ui.unit.dp * Reusable SHONAR UI primitives — every button, card, pill, and field in * the app routes through these so the design language stays uniform. * Behavior is plain pass-through: onClick fires exactly as before. + * + * Keyboard accessibility: every interactive primitive shows a visible + * focus indicator ([shonarFocusRing] or a tinted border) when reached + * via Tab, never relying on hover alone. */ +/** Focus-ring wrapper: shows a 2dp accent ring when this node or a + * child holds keyboard focus. The ONE focus treatment app-wide — + * every interactive primitive composes it in. */ +@Composable +fun Modifier.shonarFocus( + shape: androidx.compose.ui.graphics.Shape = Shonar.ShapeButton, +): Modifier { + var focused by remember { mutableStateOf(false) } + val ring = Shonar.Accent + return this + .onFocusEvent { focused = it.isFocused } + .then( + if (focused) Modifier.border(Shonar.FocusW, ring, shape) + else Modifier + ) +} + + /** App-wide card: elevated surface, hairline border, 14dp radius, * no drop shadow (reference cards sit flush in the page). */ @Composable @@ -52,7 +85,8 @@ fun SonarCard( content: @Composable () -> Unit, ) { val clickModifier = if (onClick != null) - Modifier.clickable(onClick = onClick) else Modifier + Modifier.shonarFocus(Shonar.ShapeCard).clickable(onClick = onClick) + else Modifier Surface( modifier = modifier.then(clickModifier), shape = Shonar.ShapeCard, @@ -72,7 +106,7 @@ fun ShonarButton( ) { Button( onClick = onClick, - modifier = modifier.height(Shonar.ControlH), + modifier = modifier.height(Shonar.ControlH).shonarFocus(Shonar.ShapeButton), enabled = enabled, shape = Shonar.ShapeButton, elevation = null, @@ -96,7 +130,7 @@ fun ShonarOutlinedButton( ) { OutlinedButton( onClick = onClick, - modifier = modifier.height(Shonar.ControlH), + modifier = modifier.height(Shonar.ControlH).shonarFocus(Shonar.ShapeButton), enabled = enabled, shape = Shonar.ShapeButton, border = BorderStroke(1.dp, Shonar.Border), @@ -118,7 +152,7 @@ fun ShonarGhostButton( ) { TextButton( onClick = onClick, - modifier = modifier, + modifier = modifier.shonarFocus(Shonar.ShapeButton), enabled = enabled, shape = Shonar.ShapeButton, colors = ButtonDefaults.textButtonColors( @@ -139,7 +173,7 @@ fun ShonarDangerButton( ) { OutlinedButton( onClick = onClick, - modifier = modifier.height(Shonar.ControlH), + modifier = modifier.height(Shonar.ControlH).shonarFocus(Shonar.ShapeButton), enabled = enabled, shape = Shonar.ShapeButton, border = BorderStroke(1.dp, Shonar.Error.copy(alpha = 0.5f)), @@ -179,6 +213,7 @@ fun PillChip( enabled = enabled, modifier = modifier .height(32.dp) + .shonarFocus(Shonar.ShapePill) .then( if (selected) Modifier.shadow( elevation = 8.dp, shape = Shonar.ShapePill, clip = false, @@ -222,7 +257,8 @@ fun SonarField( shape = Shonar.ShapeCard, color = Shonar.SurfaceAlt, border = BorderStroke( - 1.dp, if (isFocused) Shonar.Accent else Shonar.Border), + if (isFocused) Shonar.FocusW else Shonar.BorderW, + if (isFocused) Shonar.Accent else Shonar.Border), ) { Row( Modifier.padding(horizontal = 14.dp, vertical = 10.dp), @@ -286,3 +322,194 @@ fun HelperText( else Shonar.TextSecondary, modifier = modifier) } + +// ---- workspace-shell primitives ------------------------------------------- + +/** Icon-only control in the top bar / sidebar: square hit target, + * hairline border, visible focus ring. [label] doubles as the + * accessibility name (Compose's equivalent of an aria-label). */ +@Composable +fun ShonarIconButton( + onClick: () -> Unit, + label: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + selected: Boolean = false, + content: @Composable () -> Unit, +) { + Surface( + modifier = modifier + .size(Shonar.ControlH) + .shonarFocus(Shonar.ShapeButton) + .clickable( + enabled = enabled, + onClickLabel = null, + role = androidx.compose.ui.semantics.Role.Button, + onClick = onClick, + ), + shape = Shonar.ShapeButton, + color = if (selected) Shonar.SurfaceAlt else Color.Transparent, + border = BorderStroke( + Shonar.BorderW, + if (selected) Shonar.Accent else Shonar.Border), + ) { + Box( + Modifier.fillMaxSize().semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { content() } + } +} + +/** Sidebar navigation row: leading icon, label, optional trailing + * count. The active row gets the accent treatment — soft tinted + * container + accent label + a 3dp accent bar hugging the left edge. + * [label] is the row's accessibility name. */ +@Composable +fun NavRow( + selected: Boolean, + onClick: () -> Unit, + label: String, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + count: Int? = null, +) { + Surface( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(36.dp) + .shonarFocus(RoundedCornerShape(10.dp)), + shape = RoundedCornerShape(10.dp), + color = if (selected) Shonar.AccentGlow else Color.Transparent, + ) { + Row( + Modifier.fillMaxSize().padding(horizontal = Shonar.Space3), + verticalAlignment = Alignment.CenterVertically, + ) { + if (selected) { + // 3dp accent bar at the row's left edge — the only + // persistent accent in the sidebar. + Box(Modifier + .padding(end = Shonar.Space3) + .width(3.dp).height(18.dp) + .background(Shonar.Accent, Shonar.ShapePill)) + } else { + Spacer(Modifier.width(3.dp + Shonar.Space3)) + } + icon?.let { + it() + Spacer(Modifier.width(Shonar.Space2)) + } + Text(label, + style = MaterialTheme.typography.bodyMedium, + color = when { + selected -> Shonar.Accent + else -> Shonar.TextSecondary + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f)) + if (count != null) { + Text("$count", + style = MaterialTheme.typography.labelSmall, + color = if (selected) Shonar.Accent else Shonar.TextMuted) + } + } + } +} + +/** Small muted label pill (tags/attributes on rows). Non-interactive; + * for interactive filters use PillChip. */ +@Composable +fun TagChip( + label: String, + modifier: Modifier = Modifier, + tint: Color = Shonar.TextSecondary, +) { + Surface( + modifier = modifier.height(20.dp), + shape = Shonar.ShapePill, + color = Color.Transparent, + border = BorderStroke(Shonar.BorderW, tint.copy(alpha = 0.4f)), + ) { + Row(Modifier.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + Text(label, + style = MaterialTheme.typography.labelSmall, + color = tint, maxLines = 1) + } + } +} + +/** Quiet status dot next to text: 8dp circle in the status color. + * Conveys state by color AND the adjacent label, never color alone. */ +@Composable +fun StatusDot( + color: Color, + modifier: Modifier = Modifier, + size: androidx.compose.ui.unit.Dp = 8.dp, +) { + Box(modifier.size(size) + .background(color, CircleShape)) +} + +/** Full-width notice: engine down, batch progress, sync status. + * Elevated card + leading status dot + trailing action slot. */ +@Composable +fun Banner( + modifier: Modifier = Modifier, + statusColor: Color = Shonar.Accent, + action: (@Composable () -> Unit)? = null, + text: @Composable () -> Unit, +) { + SonarCard(modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding), + verticalAlignment = Alignment.CenterVertically) { + StatusDot(statusColor, modifier = Modifier.padding(end = Shonar.Space2)) + Column(Modifier.weight(1f)) { text() } + action?.let { + Spacer(Modifier.width(Shonar.Space2)) + it() + } + } + } +} + +/** Empty state: centered icon, one-line message, optional helper and + * primary action. Replaces bare "No files." text so empty lists read + * as intentional states, not broken screens. */ +@Composable +fun ShonarEmptyState( + title: String, + modifier: Modifier = Modifier, + helper: String? = null, + icon: (@Composable () -> Unit)? = null, + action: (@Composable () -> Unit)? = null, +) { + Column( + modifier.fillMaxWidth().padding(Shonar.Space5 * 2), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Shonar.Space3), + ) { + icon?.let { + Surface( + shape = CircleShape, + color = Shonar.SurfaceAlt, + border = BorderStroke(Shonar.BorderW, Shonar.Border), + ) { + Box(Modifier.padding(Shonar.Space4)) { it() } + } + } + Text(title, style = MaterialTheme.typography.titleSmall, + color = Shonar.TextSecondary) + if (helper != null) { + Text(helper, + style = MaterialTheme.typography.bodySmall, + color = Shonar.TextMuted) + } + action?.let { + Spacer(Modifier.height(Shonar.Space1)) + it() + } + } +}