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
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue