Components: app-wide keyboard focus rings plus workspace primitives (icon button, nav row, tag chip, status dot, banner, empty state)

This commit is contained in:
avi 2026-09-21 12:20:27 -05:00
commit 6e5a47ed5c

View file

@ -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()
}
}
}