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 package com.shonar.desktop
import androidx.compose.foundation.BorderStroke 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.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -29,9 +36,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.onFocusChanged 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.graphics.SolidColor
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType 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 * Reusable SHONAR UI primitives — every button, card, pill, and field in
* the app routes through these so the design language stays uniform. * the app routes through these so the design language stays uniform.
* Behavior is plain pass-through: onClick fires exactly as before. * 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, /** App-wide card: elevated surface, hairline border, 14dp radius,
* no drop shadow (reference cards sit flush in the page). */ * no drop shadow (reference cards sit flush in the page). */
@Composable @Composable
@ -52,7 +85,8 @@ fun SonarCard(
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val clickModifier = if (onClick != null) val clickModifier = if (onClick != null)
Modifier.clickable(onClick = onClick) else Modifier Modifier.shonarFocus(Shonar.ShapeCard).clickable(onClick = onClick)
else Modifier
Surface( Surface(
modifier = modifier.then(clickModifier), modifier = modifier.then(clickModifier),
shape = Shonar.ShapeCard, shape = Shonar.ShapeCard,
@ -72,7 +106,7 @@ fun ShonarButton(
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,
modifier = modifier.height(Shonar.ControlH), modifier = modifier.height(Shonar.ControlH).shonarFocus(Shonar.ShapeButton),
enabled = enabled, enabled = enabled,
shape = Shonar.ShapeButton, shape = Shonar.ShapeButton,
elevation = null, elevation = null,
@ -96,7 +130,7 @@ fun ShonarOutlinedButton(
) { ) {
OutlinedButton( OutlinedButton(
onClick = onClick, onClick = onClick,
modifier = modifier.height(Shonar.ControlH), modifier = modifier.height(Shonar.ControlH).shonarFocus(Shonar.ShapeButton),
enabled = enabled, enabled = enabled,
shape = Shonar.ShapeButton, shape = Shonar.ShapeButton,
border = BorderStroke(1.dp, Shonar.Border), border = BorderStroke(1.dp, Shonar.Border),
@ -118,7 +152,7 @@ fun ShonarGhostButton(
) { ) {
TextButton( TextButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier.shonarFocus(Shonar.ShapeButton),
enabled = enabled, enabled = enabled,
shape = Shonar.ShapeButton, shape = Shonar.ShapeButton,
colors = ButtonDefaults.textButtonColors( colors = ButtonDefaults.textButtonColors(
@ -139,7 +173,7 @@ fun ShonarDangerButton(
) { ) {
OutlinedButton( OutlinedButton(
onClick = onClick, onClick = onClick,
modifier = modifier.height(Shonar.ControlH), modifier = modifier.height(Shonar.ControlH).shonarFocus(Shonar.ShapeButton),
enabled = enabled, enabled = enabled,
shape = Shonar.ShapeButton, shape = Shonar.ShapeButton,
border = BorderStroke(1.dp, Shonar.Error.copy(alpha = 0.5f)), border = BorderStroke(1.dp, Shonar.Error.copy(alpha = 0.5f)),
@ -179,6 +213,7 @@ fun PillChip(
enabled = enabled, enabled = enabled,
modifier = modifier modifier = modifier
.height(32.dp) .height(32.dp)
.shonarFocus(Shonar.ShapePill)
.then( .then(
if (selected) Modifier.shadow( if (selected) Modifier.shadow(
elevation = 8.dp, shape = Shonar.ShapePill, clip = false, elevation = 8.dp, shape = Shonar.ShapePill, clip = false,
@ -222,7 +257,8 @@ fun SonarField(
shape = Shonar.ShapeCard, shape = Shonar.ShapeCard,
color = Shonar.SurfaceAlt, color = Shonar.SurfaceAlt,
border = BorderStroke( 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( Row(
Modifier.padding(horizontal = 14.dp, vertical = 10.dp), Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
@ -286,3 +322,194 @@ fun HelperText(
else Shonar.TextSecondary, else Shonar.TextSecondary,
modifier = modifier) 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()
}
}
}