commit 824dfdc2cbc8bf281d7413f70598c08ab6bf6bbd
parent 25a69d06371dc855e297c9f57d34756730109ba2
Author: triesap <tyson@radroots.org>
Date: Thu, 13 Aug 2026 15:32:10 +0000
design: port HarvestCircle choice components
- own popup menus navigation items and tab controls
- port checkbox radio and switch selection behavior
- preserve disabled semantics and independent hover state
- require visible keyboard focus across every public choice control
Diffstat:
4 files changed, 1008 insertions(+), 0 deletions(-)
diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/menu/HarvestCirclePopupButton.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/menu/HarvestCirclePopupButton.kt
@@ -0,0 +1,317 @@
+package org.harvestcircle.designsystem.component.menu
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupPositionProvider
+import androidx.compose.ui.window.PopupProperties
+import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant
+import org.harvestcircle.designsystem.component.HarvestCircleContentTone
+import org.harvestcircle.designsystem.component.HarvestCircleControlSize
+import org.harvestcircle.designsystem.component.HarvestCircleFocusRing
+import org.harvestcircle.designsystem.component.HarvestCircleIconSize
+import org.harvestcircle.designsystem.component.HarvestCircleTextRole
+import org.harvestcircle.designsystem.component.action.HarvestCircleButton
+import org.harvestcircle.designsystem.icon.HarvestCircleIcons
+import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable
+import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions
+import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources
+import org.harvestcircle.designsystem.primitive.HarvestCircleIcon
+import org.harvestcircle.designsystem.primitive.HarvestCircleSurface
+import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole
+import org.harvestcircle.designsystem.primitive.HarvestCircleText
+import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor
+import org.harvestcircle.designsystem.theme.HarvestCircleInputMode
+import org.harvestcircle.designsystem.theme.HarvestCircleTheme
+
+/** One value in a canonical macOS popup button menu. */
+public data class HarvestCircleMenuOption<T>(
+ public val value: T,
+ public val label: String,
+ public val enabled: Boolean = true,
+)
+
+private fun <T> nextEnabledIndex(
+ options: List<HarvestCircleMenuOption<T>>,
+ current: Int,
+ direction: Int,
+): Int {
+ if (options.isEmpty()) return -1
+ var candidate = current
+ repeat(options.size) {
+ candidate = (candidate + direction + options.size).mod(options.size)
+ if (options[candidate].enabled) return candidate
+ }
+ return current
+}
+
+/** Canonical macOS popup button: a compact bezeled control opening a keyboard-operable menu. */
+@Composable
+public fun <T> HarvestCirclePopupButton(
+ selectedValue: T,
+ options: List<HarvestCircleMenuOption<T>>,
+ onValueChange: (T) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ size: HarvestCircleControlSize = HarvestCircleControlSize.Medium,
+ focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused,
+) {
+ require(options.isNotEmpty()) { "HarvestCirclePopupButton requires at least one option" }
+
+ var expanded by remember { mutableStateOf(false) }
+ var anchorWidthPx by remember { mutableIntStateOf(0) }
+ var activeIndex by remember(options, selectedValue) {
+ mutableIntStateOf(options.indexOfFirst { it.value == selectedValue }.coerceAtLeast(0))
+ }
+ val selected = options.firstOrNull { it.value == selectedValue } ?: options.first()
+ val menuItemMinimumHeight =
+ if (HarvestCircleTheme.component.inputMode == HarvestCircleInputMode.Touch) {
+ HarvestCircleTheme.shell.dimensions.minimumInteractive
+ } else {
+ HarvestCircleTheme.shell.dimensions.menuItemHeight
+ }
+ val density = LocalDensity.current
+ val focusManager = LocalFocusManager.current
+ val focusRequester = remember { FocusRequester() }
+ val gapPx = with(density) { 4.dp.roundToPx() }
+ val anchorWidth = with(density) { anchorWidthPx.toDp() }
+ val positionProvider =
+ remember(gapPx) {
+ object : PopupPositionProvider {
+ override fun calculatePosition(
+ anchorBounds: IntRect,
+ windowSize: IntSize,
+ layoutDirection: LayoutDirection,
+ popupContentSize: IntSize,
+ ): IntOffset {
+ val preferredX =
+ if (layoutDirection == LayoutDirection.Ltr) {
+ anchorBounds.left
+ } else {
+ anchorBounds.right - popupContentSize.width
+ }
+ val x =
+ preferredX.coerceIn(
+ minimumValue = 0,
+ maximumValue = (windowSize.width - popupContentSize.width).coerceAtLeast(0),
+ )
+ val below = anchorBounds.bottom + gapPx
+ val y =
+ if (below + popupContentSize.height <= windowSize.height) {
+ below
+ } else {
+ (anchorBounds.top - popupContentSize.height - gapPx).coerceAtLeast(0)
+ }
+ return IntOffset(x, y)
+ }
+ }
+ }
+
+ Box(
+ modifier =
+ modifier.onGloballyPositioned {
+ anchorWidthPx = it.size.width
+ },
+ ) {
+ HarvestCircleButton(
+ onClick = { expanded = !expanded },
+ variant = HarvestCircleButtonVariant.Secondary,
+ size = size,
+ enabled = enabled,
+ focusRing = focusRing,
+ ) {
+ HarvestCircleText(
+ text = selected.label,
+ role = HarvestCircleTextRole.Label,
+ tone = HarvestCircleContentTone.Inherit,
+ maxLines = 1,
+ )
+ Spacer(Modifier.width(HarvestCircleTheme.foundation.spacing.md))
+ HarvestCircleIcon(
+ resource = HarvestCircleIcons.ChevronDown,
+ contentDescription = null,
+ size = HarvestCircleIconSize.Small,
+ tone = HarvestCircleContentTone.Inherit,
+ )
+ }
+
+ if (expanded) {
+ Popup(
+ popupPositionProvider = positionProvider,
+ onDismissRequest = {
+ expanded = false
+ focusManager.clearFocus()
+ },
+ properties = PopupProperties(focusable = true),
+ ) {
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ HarvestCircleSurface(
+ modifier =
+ Modifier
+ .widthIn(
+ min = anchorWidth.coerceAtLeast(160.dp),
+ max = 360.dp,
+ ).focusRequester(focusRequester)
+ .focusable()
+ .onPreviewKeyEvent { event ->
+ if (event.type != KeyEventType.KeyDown) {
+ return@onPreviewKeyEvent false
+ }
+ when (event.key) {
+ Key.DirectionDown -> {
+ activeIndex = nextEnabledIndex(options, activeIndex, 1)
+ true
+ }
+
+ Key.DirectionUp -> {
+ activeIndex = nextEnabledIndex(options, activeIndex, -1)
+ true
+ }
+
+ Key.Enter,
+ Key.Spacebar,
+ -> {
+ options.getOrNull(activeIndex)?.takeIf { it.enabled }?.let {
+ onValueChange(it.value)
+ expanded = false
+ }
+ true
+ }
+
+ Key.MoveHome -> {
+ activeIndex = options.indexOfFirst { it.enabled }.coerceAtLeast(0)
+ true
+ }
+
+ Key.MoveEnd -> {
+ activeIndex = options.indexOfLast { it.enabled }.coerceAtLeast(0)
+ true
+ }
+
+ Key.Escape -> {
+ expanded = false
+ true
+ }
+
+ else -> false
+ }
+ },
+ role = HarvestCircleSurfaceRole.Overlay,
+ shape = HarvestCircleTheme.foundation.shapes.control,
+ border = BorderStroke(1.dp, HarvestCircleTheme.foundation.colors.border.default),
+ shadowElevation = HarvestCircleTheme.component.elevations.overlay,
+ ) {
+ Column(modifier = Modifier.padding(HarvestCircleTheme.foundation.spacing.xs)) {
+ options.forEachIndexed { index, option ->
+ val sources = rememberHarvestCircleInteractionSources(option.value)
+ val interactions = sources.harvestCircleInteractions(option.enabled)
+ LaunchedEffect(interactions.hovered) {
+ if (interactions.hovered && option.enabled) {
+ activeIndex = index
+ }
+ }
+ val active = index == activeIndex
+ val selectedOption = option.value == selectedValue
+ val container =
+ when {
+ active && option.enabled -> HarvestCircleTheme.foundation.colors.action.primary.rest
+ else -> HarvestCircleTheme.foundation.colors.surface.overlay
+ }
+ val contentColor =
+ when {
+ !option.enabled -> HarvestCircleTheme.foundation.colors.content.disabled
+ active -> HarvestCircleTheme.foundation.colors.content.inverse
+ else -> HarvestCircleTheme.foundation.colors.content.primary
+ }
+
+ ProvideHarvestCircleContentColor(contentColor) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = menuItemMinimumHeight)
+ .clip(HarvestCircleTheme.foundation.shapes.control)
+ .background(container)
+ .harvestCircleHoverable(sources = sources, enabled = option.enabled)
+ .clickable(
+ interactionSource = sources.activationSource,
+ indication = null,
+ enabled = option.enabled,
+ role = Role.Button,
+ onClick = {
+ onValueChange(option.value)
+ expanded = false
+ },
+ ).padding(horizontal = HarvestCircleTheme.foundation.spacing.md),
+ horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier.width(HarvestCircleTheme.shell.dimensions.iconSmall),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (selectedOption) {
+ HarvestCircleIcon(
+ resource = HarvestCircleIcons.Check,
+ contentDescription = null,
+ size = HarvestCircleIconSize.Small,
+ tone = HarvestCircleContentTone.Inherit,
+ )
+ }
+ }
+ HarvestCircleText(
+ text = option.label,
+ role = HarvestCircleTextRole.Body,
+ tone = HarvestCircleContentTone.Inherit,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/navigation/HarvestCircleNavigationItem.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/navigation/HarvestCircleNavigationItem.kt
@@ -0,0 +1,122 @@
+package org.harvestcircle.designsystem.component.navigation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.Role
+import org.harvestcircle.designsystem.component.HarvestCircleContentTone
+import org.harvestcircle.designsystem.component.HarvestCircleFocusRing
+import org.harvestcircle.designsystem.component.HarvestCircleIconSize
+import org.harvestcircle.designsystem.component.HarvestCircleTextRole
+import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacFocusFrame
+import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable
+import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions
+import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources
+import org.harvestcircle.designsystem.primitive.HarvestCircleIcon
+import org.harvestcircle.designsystem.primitive.HarvestCircleText
+import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor
+import org.harvestcircle.designsystem.theme.HarvestCircleTheme
+import org.jetbrains.compose.resources.DrawableResource
+
+/**
+ * Inset macOS sidebar row.
+ *
+ * [emphasized] represents an active key window. Set it to false for an inactive-window selection.
+ * The optional focus ring is disabled by default.
+ */
+@Composable
+public fun HarvestCircleNavigationItem(
+ selected: Boolean,
+ onClick: () -> Unit,
+ label: String,
+ modifier: Modifier = Modifier,
+ icon: DrawableResource? = null,
+ enabled: Boolean = true,
+ trailingContent: (@Composable RowScope.() -> Unit)? = null,
+ emphasized: Boolean = true,
+ focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused,
+) {
+ val sources = rememberHarvestCircleInteractionSources()
+ val interactions = sources.harvestCircleInteractions(enabled)
+ val container =
+ when {
+ !enabled -> Color.Transparent
+ selected && emphasized -> HarvestCircleTheme.foundation.colors.action.primary.rest
+ selected -> HarvestCircleTheme.foundation.colors.surface.selected
+ interactions.pressed -> HarvestCircleTheme.foundation.colors.action.ghost.pressed
+ interactions.hovered -> HarvestCircleTheme.foundation.colors.action.ghost.hover
+ else -> Color.Transparent
+ }
+ val contentColor =
+ when {
+ !enabled -> HarvestCircleTheme.foundation.colors.content.disabled
+ selected && emphasized -> HarvestCircleTheme.foundation.colors.content.inverse
+ else -> HarvestCircleTheme.foundation.colors.content.primary
+ }
+
+ Box(
+ modifier =
+ modifier
+ .defaultMinSize(minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive)
+ .harvestCircleHoverable(sources = sources, enabled = enabled)
+ .selectable(
+ selected = selected,
+ interactionSource = sources.activationSource,
+ indication = null,
+ enabled = enabled,
+ role = Role.Tab,
+ onClick = onClick,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ HarvestCircleMacFocusFrame(
+ focused = interactions.focused,
+ focusRing = focusRing,
+ shape = HarvestCircleTheme.foundation.shapes.control,
+ ringShape = HarvestCircleTheme.foundation.shapes.controlFocusRing,
+ ) {
+ ProvideHarvestCircleContentColor(contentColor) {
+ Row(
+ modifier =
+ Modifier
+ .height(HarvestCircleTheme.shell.dimensions.rowHeight)
+ .clip(HarvestCircleTheme.foundation.shapes.control)
+ .background(container)
+ .padding(horizontal = HarvestCircleTheme.foundation.spacing.md),
+ horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.md),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (icon != null) {
+ HarvestCircleIcon(
+ resource = icon,
+ contentDescription = null,
+ size = HarvestCircleIconSize.Medium,
+ tone = HarvestCircleContentTone.Inherit,
+ )
+ }
+
+ HarvestCircleText(
+ text = label,
+ modifier = Modifier.weight(1f),
+ role = HarvestCircleTextRole.Label,
+ tone = HarvestCircleContentTone.Inherit,
+ maxLines = 1,
+ )
+
+ trailingContent?.invoke(this)
+ }
+ }
+ }
+ }
+}
diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/navigation/HarvestCircleTabs.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/navigation/HarvestCircleTabs.kt
@@ -0,0 +1,136 @@
+package org.harvestcircle.designsystem.component.navigation
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.designsystem.component.HarvestCircleContentTone
+import org.harvestcircle.designsystem.component.HarvestCircleFocusRing
+import org.harvestcircle.designsystem.component.HarvestCircleTextRole
+import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacBezel
+import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacFocusFrame
+import org.harvestcircle.designsystem.internal.chrome.harvestCircleMacControlBrush
+import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable
+import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions
+import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources
+import org.harvestcircle.designsystem.primitive.HarvestCircleText
+import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor
+import org.harvestcircle.designsystem.theme.HarvestCircleTheme
+
+/** AppKit-style segmented control container. */
+@Composable
+public fun HarvestCircleTabRow(
+ modifier: Modifier = Modifier,
+ content: @Composable RowScope.() -> Unit,
+) {
+ Row(
+ modifier =
+ modifier
+ .clip(HarvestCircleTheme.foundation.shapes.control)
+ .background(HarvestCircleTheme.foundation.colors.surface.sunken)
+ .border(
+ width = 1.dp,
+ color = HarvestCircleTheme.foundation.colors.border.default,
+ shape = HarvestCircleTheme.foundation.shapes.control,
+ ).padding(HarvestCircleTheme.foundation.spacing.xxs),
+ verticalAlignment = Alignment.CenterVertically,
+ content = content,
+ )
+}
+
+/** One selectable segment inside [HarvestCircleTabRow]. */
+@Composable
+public fun RowScope.HarvestCircleTab(
+ selected: Boolean,
+ onClick: () -> Unit,
+ label: String,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused,
+) {
+ val sources = rememberHarvestCircleInteractionSources()
+ val interactions = sources.harvestCircleInteractions(enabled)
+ val container =
+ when {
+ !enabled -> Color.Transparent
+ selected -> HarvestCircleTheme.foundation.colors.surface.raised
+ interactions.pressed -> HarvestCircleTheme.foundation.colors.action.ghost.pressed
+ interactions.hovered -> HarvestCircleTheme.foundation.colors.action.ghost.hover
+ else -> Color.Transparent
+ }
+ val border =
+ if (selected) {
+ HarvestCircleTheme.foundation.colors.border.subtle
+ } else {
+ Color.Transparent
+ }
+
+ Box(
+ modifier =
+ modifier
+ .defaultMinSize(minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive)
+ .harvestCircleHoverable(sources = sources, enabled = enabled)
+ .selectable(
+ selected = selected,
+ interactionSource = sources.activationSource,
+ indication = null,
+ enabled = enabled,
+ role = Role.Tab,
+ onClick = onClick,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ HarvestCircleMacFocusFrame(
+ focused = interactions.focused,
+ focusRing = focusRing,
+ shape = HarvestCircleTheme.foundation.shapes.control,
+ ringShape = HarvestCircleTheme.foundation.shapes.controlFocusRing,
+ ) {
+ HarvestCircleMacBezel(
+ modifier = Modifier.height(HarvestCircleTheme.shell.dimensions.controlMedium),
+ brush = harvestCircleMacControlBrush(container),
+ border = BorderStroke(1.dp, border),
+ shape = HarvestCircleTheme.foundation.shapes.control,
+ shadowElevation = if (selected) HarvestCircleTheme.component.elevations.raised else 0.dp,
+ ) {
+ ProvideHarvestCircleContentColor(
+ if (enabled) {
+ HarvestCircleTheme.foundation.colors.content.primary
+ } else {
+ HarvestCircleTheme.foundation.colors.content.disabled
+ },
+ ) {
+ HarvestCircleText(
+ text = label,
+ modifier =
+ Modifier.padding(
+ horizontal = HarvestCircleTheme.foundation.spacing.lg,
+ vertical = HarvestCircleTheme.foundation.spacing.xs,
+ ),
+ role = HarvestCircleTextRole.Label,
+ tone =
+ if (enabled) {
+ HarvestCircleContentTone.Inherit
+ } else {
+ HarvestCircleContentTone.Disabled
+ },
+ maxLines = 1,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/selection/HarvestCircleSelectionControls.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/selection/HarvestCircleSelectionControls.kt
@@ -0,0 +1,433 @@
+package org.harvestcircle.designsystem.component.selection
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.designsystem.component.HarvestCircleContentTone
+import org.harvestcircle.designsystem.component.HarvestCircleFocusRing
+import org.harvestcircle.designsystem.component.HarvestCircleIconSize
+import org.harvestcircle.designsystem.component.HarvestCircleTextRole
+import org.harvestcircle.designsystem.icon.HarvestCircleIcons
+import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacFocusFrame
+import org.harvestcircle.designsystem.internal.chrome.harvestCircleMacControlBrush
+import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable
+import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions
+import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources
+import org.harvestcircle.designsystem.primitive.HarvestCircleIcon
+import org.harvestcircle.designsystem.primitive.HarvestCircleText
+import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor
+import org.harvestcircle.designsystem.theme.HarvestCircleTheme
+
+private val CheckboxShape = RoundedCornerShape(3.dp)
+private val CheckboxFocusRingShape = RoundedCornerShape(7.dp)
+
+@Composable
+private fun HarvestCircleCheckboxVisual(
+ checked: Boolean,
+ enabled: Boolean,
+ hovered: Boolean,
+ focused: Boolean,
+ focusRing: HarvestCircleFocusRing,
+) {
+ val colors = HarvestCircleTheme.foundation.colors
+ val container =
+ when {
+ !enabled && checked -> colors.action.primary.disabled
+ !enabled -> colors.surface.sunken
+ checked -> colors.action.primary.rest
+ hovered -> colors.action.ghost.hover
+ else -> colors.surface.raised
+ }
+ val border =
+ when {
+ !enabled -> colors.border.subtle
+ checked -> lerp(colors.action.primary.rest, Color.Black, 0.16f)
+ hovered -> colors.border.strong
+ else -> colors.border.default
+ }
+
+ HarvestCircleMacFocusFrame(
+ focused = focused,
+ focusRing = focusRing,
+ shape = CheckboxShape,
+ ringShape = CheckboxFocusRingShape,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size(HarvestCircleTheme.shell.dimensions.selectionControl)
+ .clip(CheckboxShape)
+ .background(
+ brush = harvestCircleMacControlBrush(container, emphasized = checked),
+ shape = CheckboxShape,
+ ).border(BorderStroke(1.dp, border), CheckboxShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (checked) {
+ ProvideHarvestCircleContentColor(
+ if (enabled) colors.action.primary.content else colors.content.disabled,
+ ) {
+ HarvestCircleIcon(
+ resource = HarvestCircleIcons.Check,
+ contentDescription = null,
+ size = HarvestCircleIconSize.Small,
+ tone = HarvestCircleContentTone.Inherit,
+ )
+ }
+ }
+ }
+ }
+}
+
+/** Canonical AppKit checkbox with an optional merged, clickable label. */
+@Composable
+public fun HarvestCircleCheckbox(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ label: String? = null,
+ focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused,
+) {
+ val sources = rememberHarvestCircleInteractionSources()
+ val interactions = sources.harvestCircleInteractions(enabled)
+ val toggleModifier =
+ modifier
+ .defaultMinSize(
+ minWidth = HarvestCircleTheme.shell.dimensions.minimumInteractive,
+ minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive,
+ ).harvestCircleHoverable(sources = sources, enabled = enabled)
+ .toggleable(
+ value = checked,
+ interactionSource = sources.activationSource,
+ indication = null,
+ enabled = enabled,
+ role = Role.Checkbox,
+ onValueChange = onCheckedChange,
+ )
+
+ if (label == null) {
+ Box(
+ modifier = toggleModifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ HarvestCircleCheckboxVisual(
+ checked = checked,
+ enabled = enabled,
+ hovered = interactions.hovered,
+ focused = interactions.focused,
+ focusRing = focusRing,
+ )
+ }
+ } else {
+ Row(
+ modifier = toggleModifier,
+ horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ HarvestCircleCheckboxVisual(
+ checked = checked,
+ enabled = enabled,
+ hovered = interactions.hovered,
+ focused = interactions.focused,
+ focusRing = focusRing,
+ )
+ HarvestCircleText(
+ text = label,
+ role = HarvestCircleTextRole.Body,
+ tone = if (enabled) HarvestCircleContentTone.Primary else HarvestCircleContentTone.Disabled,
+ )
+ }
+ }
+}
+
+@Composable
+private fun HarvestCircleRadioVisual(
+ selected: Boolean,
+ enabled: Boolean,
+ hovered: Boolean,
+ focused: Boolean,
+ focusRing: HarvestCircleFocusRing,
+) {
+ val colors = HarvestCircleTheme.foundation.colors
+ val container =
+ when {
+ !enabled && selected -> colors.action.primary.disabled
+ !enabled -> colors.surface.sunken
+ selected -> colors.action.primary.rest
+ hovered -> colors.action.ghost.hover
+ else -> colors.surface.raised
+ }
+ val border =
+ when {
+ !enabled -> colors.border.subtle
+ selected -> lerp(colors.action.primary.rest, Color.Black, 0.16f)
+ hovered -> colors.border.strong
+ else -> colors.border.default
+ }
+
+ HarvestCircleMacFocusFrame(
+ focused = focused,
+ focusRing = focusRing,
+ shape = CircleShape,
+ ringShape = CircleShape,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size(HarvestCircleTheme.shell.dimensions.selectionControl)
+ .clip(CircleShape)
+ .background(
+ brush = harvestCircleMacControlBrush(container, emphasized = selected),
+ shape = CircleShape,
+ ).border(BorderStroke(1.dp, border), CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (selected) {
+ Box(
+ modifier =
+ Modifier
+ .size(HarvestCircleTheme.shell.dimensions.selectionControl * 0.38f)
+ .clip(CircleShape)
+ .background(
+ if (enabled) {
+ colors.action.primary.content
+ } else {
+ colors.content.disabled
+ },
+ ),
+ )
+ }
+ }
+ }
+}
+
+/** Canonical AppKit radio button with an optional merged, clickable label. */
+@Composable
+public fun HarvestCircleRadioButton(
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ label: String? = null,
+ focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused,
+) {
+ val sources = rememberHarvestCircleInteractionSources()
+ val interactions = sources.harvestCircleInteractions(enabled)
+ val selectableModifier =
+ modifier
+ .defaultMinSize(
+ minWidth = HarvestCircleTheme.shell.dimensions.minimumInteractive,
+ minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive,
+ ).harvestCircleHoverable(sources = sources, enabled = enabled)
+ .selectable(
+ selected = selected,
+ interactionSource = sources.activationSource,
+ indication = null,
+ enabled = enabled,
+ role = Role.RadioButton,
+ onClick = onClick,
+ )
+
+ if (label == null) {
+ Box(
+ modifier = selectableModifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ HarvestCircleRadioVisual(
+ selected = selected,
+ enabled = enabled,
+ hovered = interactions.hovered,
+ focused = interactions.focused,
+ focusRing = focusRing,
+ )
+ }
+ } else {
+ Row(
+ modifier = selectableModifier,
+ horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ HarvestCircleRadioVisual(
+ selected = selected,
+ enabled = enabled,
+ hovered = interactions.hovered,
+ focused = interactions.focused,
+ focusRing = focusRing,
+ )
+ HarvestCircleText(
+ text = label,
+ role = HarvestCircleTextRole.Body,
+ tone = if (enabled) HarvestCircleContentTone.Primary else HarvestCircleContentTone.Disabled,
+ )
+ }
+ }
+}
+
+@Composable
+private fun HarvestCircleSwitchVisual(
+ checked: Boolean,
+ enabled: Boolean,
+ hovered: Boolean,
+ focused: Boolean,
+ focusRing: HarvestCircleFocusRing,
+) {
+ val colors = HarvestCircleTheme.foundation.colors
+ val width = HarvestCircleTheme.shell.dimensions.switchWidth
+ val height = HarvestCircleTheme.shell.dimensions.switchHeight
+ val knob = height - 4.dp
+ val travel = width - knob - 4.dp
+ val direction = LocalLayoutDirection.current
+ val targetOffset =
+ when {
+ checked && direction == LayoutDirection.Ltr -> travel
+ !checked && direction == LayoutDirection.Rtl -> travel
+ else -> 0.dp
+ }
+ val duration = HarvestCircleTheme.component.motion.standardMillis
+ val knobOffset =
+ animateDpAsState(
+ targetValue = targetOffset,
+ animationSpec = if (duration == 0) snap() else tween(durationMillis = duration),
+ label = "HarvestCircleSwitchKnob",
+ ).value
+ val track =
+ when {
+ !enabled && checked -> colors.action.primary.disabled
+ !enabled -> colors.surface.sunken
+ checked -> colors.action.primary.rest
+ hovered -> colors.border.default
+ else -> colors.surface.sunken
+ }
+ val border =
+ when {
+ !enabled -> colors.border.subtle
+ checked -> lerp(colors.action.primary.rest, Color.Black, 0.16f)
+ hovered -> colors.border.strong
+ else -> colors.border.default
+ }
+
+ HarvestCircleMacFocusFrame(
+ focused = focused,
+ focusRing = focusRing,
+ shape = CircleShape,
+ ringShape = CircleShape,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size(width = width, height = height)
+ .clip(CircleShape)
+ .background(track)
+ .border(BorderStroke(1.dp, border), CircleShape),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .offset(x = 2.dp + knobOffset)
+ .size(knob)
+ .shadow(1.dp, CircleShape, clip = false)
+ .clip(CircleShape)
+ .background(
+ if (enabled) {
+ colors.surface.raised
+ } else {
+ colors.content.disabled
+ },
+ ).border(
+ BorderStroke(1.dp, colors.border.subtle),
+ CircleShape,
+ ),
+ )
+ }
+ }
+}
+
+/** Canonical AppKit switch with an optional merged, clickable label. */
+@Composable
+public fun HarvestCircleSwitch(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ label: String? = null,
+ focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused,
+) {
+ val sources = rememberHarvestCircleInteractionSources()
+ val interactions = sources.harvestCircleInteractions(enabled)
+ val toggleModifier =
+ modifier
+ .defaultMinSize(
+ minWidth = HarvestCircleTheme.shell.dimensions.minimumInteractive,
+ minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive,
+ ).harvestCircleHoverable(sources = sources, enabled = enabled)
+ .toggleable(
+ value = checked,
+ interactionSource = sources.activationSource,
+ indication = null,
+ enabled = enabled,
+ role = Role.Switch,
+ onValueChange = onCheckedChange,
+ )
+
+ if (label == null) {
+ Box(
+ modifier = toggleModifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ HarvestCircleSwitchVisual(
+ checked = checked,
+ enabled = enabled,
+ hovered = interactions.hovered,
+ focused = interactions.focused,
+ focusRing = focusRing,
+ )
+ }
+ } else {
+ Row(
+ modifier = toggleModifier.padding(vertical = HarvestCircleTheme.foundation.spacing.xs),
+ horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.md),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ HarvestCircleText(
+ text = label,
+ modifier = Modifier.weight(1f),
+ role = HarvestCircleTextRole.Body,
+ tone = if (enabled) HarvestCircleContentTone.Primary else HarvestCircleContentTone.Disabled,
+ )
+ HarvestCircleSwitchVisual(
+ checked = checked,
+ enabled = enabled,
+ hovered = interactions.hovered,
+ focused = interactions.focused,
+ focusRing = focusRing,
+ )
+ }
+ }
+}