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:
parent
f3a84a2333
commit
6e5a47ed5c
1 changed files with 233 additions and 6 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue