Husi Actions Dropdown Menus
Use this skill when a topbar actions = { ... } block contains an overflow menu, usually a
more_vert SimpleIconButton wrapped by CapsuleActionButton, and the menu is implemented with
Material3 Expressive DropdownMenuPopup.
If the task also changes topbar structure, first follow the husi-topbar skill. This skill only
covers the overflow menu content and its visual hierarchy.
Current house pattern
Actions overflow menus should look like this:
CapsuleActionButton {
Box {
SimpleIconButton(
imageVector = vectorResource(Res.drawable.more_vert),
contentDescription = stringResource(Res.string.more),
isOverflowMenuExpanded = true },
)
DropdownMenuPopup(
expanded = isOverflowMenuExpanded,
isOverflowMenuExpanded = false },
) {
DropdownMenuGroup(
shapes = MenuDefaults.groupShape(0, 1),
) {
DropdownMenuSectionHeader(stringResource(Res.string.custom_config))
DropdownMenuItem(
text = { Text(stringResource(Res.string.outbound)) },
isOverflowMenuExpanded = false
// action
},
shape = MenuDefaults.itemShape(0, 2).shape,
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.full)) },
isOverflowMenuExpanded = false
// action
},
shape = MenuDefaults.itemShape(1, 2).shape,
)
}
}
}
}
Required pieces:
- Wrap the overflow icon in
CapsuleActionButton, and place the popup in the sameBox. - Use
DropdownMenuPopup, not the older plainDropdownMenu, for new topbar actions menus unless the surrounding screen already intentionally uses plainDropdownMenu. - Use
DropdownMenuGroupfor each visual group and separate groups withSpacer(Modifier.height(MenuDefaults.GroupSpacing)). - Use
MenuDefaults.groupShape(groupIndex, groupCount)for grouped popup sections.
Section headers
Section headers in actions menus are labels, not actions. Do not create fake title rows like:
DropdownMenuItem(
text = { MenuDefaults.Label { Text(...) } },
)
That makes the title look and behave like a normal clickable menu row. It also adds misleading touch/ripple semantics.
Use the shared helper instead:
DropdownMenuSectionHeader(stringResource(Res.string.sort_mode))
The helper lives at:
composeApp/src/commonMain/kotlin/fr/husi/compose/DropdownMenuSectionHeader.kt
It applies the current house style:
MenuDefaults.Label- full-width centered text
MaterialTheme.typography.labelMediumFontWeight.SemiBoldMaterialTheme.colorScheme.onSurfaceVariantHorizontalDividerwithMenuDefaults.HorizontalDividerPadding
If the helper is missing in an older branch, add it instead of duplicating the style in each screen.
Item shapes after headers
Headers do not count as menu items for item shape indexing. Count only the actionable rows in that group.
For plain click rows, Material3's API takes a single Shape, so use .shape:
DropdownMenuItem(
text = { Text(stringResource(Res.string.full)) },
/* ... */ },
shape = MenuDefaults.itemShape(index, itemCount).shape,
)
For selected or checked rows, Material3's API takes MenuItemShapes, so use shapes:
DropdownMenuItem(
selected = sortMode == uiState.sortMode,
/* ... */ },
text = { Text(stringResource(text)) },
shapes = MenuDefaults.itemShape(index, itemCount),
)
DropdownMenuItem(
checked = sortMode == uiState.sortMode,
checked ->
if (!checked) return@DropdownMenuItem
// action
},
text = { Text(stringResource(text)) },
shapes = MenuDefaults.itemShape(index, itemCount),
)
This distinction matters: using shapes = ... on a plain onClick item will not compile, while
using shape = MenuDefaults.itemShape(...).shape preserves the grouped rounded-corner behavior.
Checked filters
For rows that toggle a boolean and show a checkbox, prefer making the row itself the action:
DropdownMenuItem(
text = { Text(stringResource(Res.string.connection_status_active)) },
viewModel.setQueryActivate(!uiState.showActivate)
},
leadingIcon = {
Checkbox(
checked = uiState.showActivate,
)
},
shape = MenuDefaults.itemShape(0, 2).shape,
)
Use onCheckedChange = null on the checkbox when the containing row handles the click. This keeps
the interaction target simple and avoids nested competing actions.
Dismiss behavior
Close the popup when the action navigates, opens another screen/dialog, or commits a one-shot selection:
onClick = {
isOverflowMenuExpanded = false
onOpenConfigEditor(...)
}
For non-destructive filter toggles that can be toggled repeatedly, leaving the menu open can be reasonable. Match the surrounding menu's behavior.
Imports and local components
Prefer Husi wrappers inside menu content:
fr.husi.compose.material3.Textfr.husi.compose.material3.Iconfr.husi.compose.material3.Checkboxfr.husi.compose.DropdownMenuSectionHeader
Use Compose resource helpers:
stringResource(Res.string...)vectorResource(Res.drawable...)
Avoid hardcoded colors or text sizes in menu rows. Use MaterialTheme roles in shared helpers only
when the visual treatment is a deliberate app convention.
Search and verification
Before editing:
rg -n "DropdownMenuPopup|MenuDefaults\\.Label|more_vert|actions\\s*=" composeApp/src/commonMain/kotlin/fr/husi -g '*.kt'
After editing:
rg -n "MenuDefaults\\.Label" composeApp/src/commonMain/kotlin/fr/husi -g '*.kt'
./gradlew :composeApp:compileKotlinDesktop
git diff --check -- composeApp/src/commonMain/kotlin/fr/husi
If the change touches Android-only UI or resources, also run:
./gradlew :composeApp:compileAndroidMain
Known reference implementations:
composeApp/src/commonMain/kotlin/fr/husi/ui/RouteSettingsScreen.ktcomposeApp/src/commonMain/kotlin/fr/husi/ui/dashboard/Dashboard.ktcomposeApp/src/commonMain/kotlin/fr/husi/ui/profile/ProfileEditorScreen.ktcomposeApp/src/commonMain/kotlin/fr/husi/compose/DropdownMenuSectionHeader.kt