Bug 1948618 - Refactor the SearchSelector composable to be driven from state r=android-reviewers,gl

SearchSelector will have it's entire UX configured through plain data and will
hoist user interactions through a standardized
> onInteraction: (BrowserToolbarEvent) -> Unit
call.

Differential Revision: https://phabricator.services.mozilla.com/D238984
This commit is contained in:
Mugurell
2025-03-25 19:08:54 +00:00
parent 787b23a23e
commit 7fa78bcec3
10 changed files with 133 additions and 24 deletions

View File

@@ -36,6 +36,7 @@ dependencies {
implementation project(":compose-base") implementation project(":compose-base")
implementation project(":concept-engine") implementation project(":concept-engine")
implementation project(":concept-menu") implementation project(":concept-menu")
implementation project(":browser-menu2")
implementation project(":browser-state") implementation project(":browser-state")
implementation project(":feature-session") implementation project(":feature-session")
implementation project(":lib-state") implementation project(":lib-state")

View File

@@ -14,12 +14,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.theme.AcornTheme import mozilla.components.compose.base.theme.AcornTheme
import mozilla.components.compose.browser.toolbar.concept.Action import mozilla.components.compose.browser.toolbar.concept.Action
import mozilla.components.compose.browser.toolbar.concept.Action.ActionButton import mozilla.components.compose.browser.toolbar.concept.Action.ActionButton
import mozilla.components.compose.browser.toolbar.concept.Action.CustomAction import mozilla.components.compose.browser.toolbar.concept.Action.CustomAction
import mozilla.components.compose.browser.toolbar.concept.Action.DropdownAction
import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent
import mozilla.components.compose.browser.toolbar.ui.SearchSelector import mozilla.components.compose.browser.toolbar.ui.SearchSelector
import mozilla.components.ui.icons.R import mozilla.components.ui.icons.R
@@ -27,10 +30,12 @@ import mozilla.components.ui.icons.R
* A container for displaying [Action]s. * A container for displaying [Action]s.
* *
* @param actions List of [Action]s to display in the container. * @param actions List of [Action]s to display in the container.
* @param onInteraction Callback for handling [BrowserToolbarEvent]s on user interactions.
*/ */
@Composable @Composable
fun ActionContainer( fun ActionContainer(
actions: List<Action>, actions: List<Action>,
onInteraction: (BrowserToolbarEvent) -> Unit,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
for (action in actions) { for (action in actions) {
@@ -41,6 +46,15 @@ fun ActionContainer(
is CustomAction -> { is CustomAction -> {
action.content() action.content()
} }
is DropdownAction -> {
SearchSelector(
icon = action.icon,
contentDescription = stringResource(action.contentDescription),
menu = action.menu,
onInteraction = { onInteraction(it) },
)
}
} }
} }
} }
@@ -84,6 +98,7 @@ private fun ActionContainerPreview() {
}, },
), ),
), ),
onInteraction = {},
) )
} }
} }

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.theme.AcornTheme import mozilla.components.compose.base.theme.AcornTheme
import mozilla.components.compose.browser.toolbar.concept.Action import mozilla.components.compose.browser.toolbar.concept.Action
import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent
private val ROUNDED_CORNER_SHAPE = RoundedCornerShape(8.dp) private val ROUNDED_CORNER_SHAPE = RoundedCornerShape(8.dp)
@@ -42,6 +43,7 @@ private val ROUNDED_CORNER_SHAPE = RoundedCornerShape(8.dp)
* display toolbar (outside of the URL bounding box). Also see: * display toolbar (outside of the URL bounding box). Also see:
* [MDN docs](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/user_interface/Browser_action) * [MDN docs](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/user_interface/Browser_action)
* @param onUrlClicked Will be called when the user clicks on the URL. * @param onUrlClicked Will be called when the user clicks on the URL.
* @param onInteraction Callback for handling [BrowserToolbarEvent]s on user interactions.
*/ */
@Composable @Composable
fun BrowserDisplayToolbar( fun BrowserDisplayToolbar(
@@ -52,6 +54,7 @@ fun BrowserDisplayToolbar(
pageActions: List<Action> = emptyList(), pageActions: List<Action> = emptyList(),
browserActions: List<Action> = emptyList(), browserActions: List<Action> = emptyList(),
onUrlClicked: () -> Unit = {}, onUrlClicked: () -> Unit = {},
onInteraction: (BrowserToolbarEvent) -> Unit = {},
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -60,7 +63,10 @@ fun BrowserDisplayToolbar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (navigationActions.isNotEmpty()) { if (navigationActions.isNotEmpty()) {
ActionContainer(actions = navigationActions) ActionContainer(
actions = navigationActions,
onInteraction = onInteraction,
)
} else { } else {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
@@ -86,11 +92,17 @@ fun BrowserDisplayToolbar(
style = textStyle, style = textStyle,
) )
ActionContainer(actions = pageActions) ActionContainer(
actions = pageActions,
onInteraction = onInteraction,
)
} }
if (browserActions.isNotEmpty()) { if (browserActions.isNotEmpty()) {
ActionContainer(actions = browserActions) ActionContainer(
actions = browserActions,
onInteraction = onInteraction,
)
} else { } else {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }

View File

@@ -31,6 +31,7 @@ import mozilla.components.compose.base.theme.AcornTheme
import mozilla.components.compose.browser.toolbar.concept.Action import mozilla.components.compose.browser.toolbar.concept.Action
import mozilla.components.compose.browser.toolbar.concept.Action.ActionButton import mozilla.components.compose.browser.toolbar.concept.Action.ActionButton
import mozilla.components.compose.browser.toolbar.concept.Action.CustomAction import mozilla.components.compose.browser.toolbar.concept.Action.CustomAction
import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent
import mozilla.components.compose.browser.toolbar.ui.InlineAutocompleteTextField import mozilla.components.compose.browser.toolbar.ui.InlineAutocompleteTextField
import mozilla.components.compose.browser.toolbar.ui.SearchSelector import mozilla.components.compose.browser.toolbar.ui.SearchSelector
import mozilla.components.ui.icons.R as iconsR import mozilla.components.ui.icons.R as iconsR
@@ -53,8 +54,10 @@ private val ROUNDED_CORNER_SHAPE = RoundedCornerShape(8.dp)
* parameter of the callback. * parameter of the callback.
* @param onUrlCommitted Will be called when the user has finished editing and wants to initiate * @param onUrlCommitted Will be called when the user has finished editing and wants to initiate
* loading the entered URL. The committed text value comes as a parameter of the callback. * loading the entered URL. The committed text value comes as a parameter of the callback.
* @param onInteraction Callback for handling [BrowserToolbarEvent]s on user interactions.
*/ */
@Composable @Composable
@Suppress("LongMethod")
fun BrowserEditToolbar( fun BrowserEditToolbar(
url: String, url: String,
colors: BrowserEditToolbarColors, colors: BrowserEditToolbarColors,
@@ -63,6 +66,7 @@ fun BrowserEditToolbar(
editActionsEnd: List<Action> = emptyList(), editActionsEnd: List<Action> = emptyList(),
onUrlEdit: (String) -> Unit = {}, onUrlEdit: (String) -> Unit = {},
onUrlCommitted: (String) -> Unit = {}, onUrlCommitted: (String) -> Unit = {},
onInteraction: (BrowserToolbarEvent) -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -98,11 +102,17 @@ fun BrowserEditToolbar(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = ROUNDED_CORNER_SHAPE, shape = ROUNDED_CORNER_SHAPE,
leadingIcon = { leadingIcon = {
ActionContainer(actions = editActionsStart) ActionContainer(
actions = editActionsStart,
onInteraction = onInteraction,
)
}, },
trailingIcon = { trailingIcon = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
ActionContainer(actions = editActionsEnd) ActionContainer(
actions = editActionsEnd,
onInteraction = onInteraction,
)
if (url.isNotEmpty()) { if (url.isNotEmpty()) {
ClearButton( ClearButton(
@@ -114,7 +124,10 @@ fun BrowserEditToolbar(
}, },
) )
} else { } else {
ActionContainer(actions = editActionsStart) ActionContainer(
actions = editActionsStart,
onInteraction = onInteraction,
)
InlineAutocompleteTextField( InlineAutocompleteTextField(
url = url, url = url,
@@ -124,7 +137,10 @@ fun BrowserEditToolbar(
onUrlCommitted = onUrlCommitted, onUrlCommitted = onUrlCommitted,
) )
ActionContainer(actions = editActionsEnd) ActionContainer(
actions = editActionsEnd,
onInteraction = onInteraction,
)
if (url.isNotEmpty()) { if (url.isNotEmpty()) {
ClearButton( ClearButton(
@@ -192,6 +208,7 @@ private fun BrowserEditToolbarPreview() {
onClick = {}, onClick = {},
), ),
), ),
onInteraction = {},
) )
} }
} }

View File

@@ -62,6 +62,7 @@ fun BrowserToolbar(
editActionsEnd = uiState.editState.editActionsEnd, editActionsEnd = uiState.editState.editActionsEnd,
onUrlCommitted = { text -> onTextCommit(text) }, onUrlCommitted = { text -> onTextCommit(text) },
onUrlEdit = { text -> onTextEdit(text) }, onUrlEdit = { text -> onTextEdit(text) },
onInteraction = { store.dispatch(it) },
) )
} }
@@ -75,6 +76,7 @@ fun BrowserToolbar(
onUrlClicked = { onUrlClicked = {
onDisplayToolbarClick() onDisplayToolbarClick()
}, },
onInteraction = { store.dispatch(it) },
) )
} }

View File

@@ -52,7 +52,10 @@ fun CustomTabToolbar(
.background(color = colors.background), .background(color = colors.background),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
ActionContainer(actions = navigationActions) ActionContainer(
actions = navigationActions,
onInteraction = {},
)
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -77,9 +80,15 @@ fun CustomTabToolbar(
} }
} }
ActionContainer(actions = pageActions) ActionContainer(
actions = pageActions,
onInteraction = {},
)
ActionContainer(actions = browserActions) ActionContainer(
actions = browserActions,
onInteraction = {},
)
} }
} }

View File

@@ -4,9 +4,12 @@
package mozilla.components.compose.browser.toolbar.concept package mozilla.components.compose.browser.toolbar.concept
import android.graphics.drawable.Drawable
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarMenu
/** /**
* Actions that can be added to the toolbar. * Actions that can be added to the toolbar.
@@ -36,4 +39,19 @@ sealed class Action {
data class CustomAction( data class CustomAction(
val content: @Composable () -> Unit, val content: @Composable () -> Unit,
) : Action() ) : Action()
/**
* An action button styled as a dropdown button to be added to the toolbar.
* This wraps the provided [icon] at the start with a down arrow to it's right to indicate that
* clicking this will open a dropdown menu.
*
* @property icon The icon for this button.
* @property contentDescription The content description for this button.
* @property menu The [BrowserToolbarMenu] to show when this button is clicked.
*/
data class DropdownAction(
val icon: Drawable,
@StringRes val contentDescription: Int,
val menu: BrowserToolbarMenu,
) : Action()
} }

View File

@@ -35,8 +35,8 @@ sealed interface BrowserToolbarInteraction {
/** /**
* Item which can be shown in a [BrowserToolbarMenu]. * Item which can be shown in a [BrowserToolbarMenu].
* *
* @property icon Optional icon to show in the popup item. * @property icon Optional icon for the menu item.
* @property text Optional text to show in the popup item. * @property text Optional text for the menu item.
* @property contentDescription Content description for this item. `null` if not important for accessibility. * @property contentDescription Content description for this item. `null` if not important for accessibility.
* @property onClick Optional [BrowserToolbarEvent] to be dispatched when this item is clicked. * @property onClick Optional [BrowserToolbarEvent] to be dispatched when this item is clicked.
*/ */

View File

@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card import androidx.compose.material.Card
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -32,14 +33,20 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import mozilla.components.browser.menu2.BrowserMenuController
import mozilla.components.compose.base.theme.AcornTheme import mozilla.components.compose.base.theme.AcornTheme
import mozilla.components.compose.browser.toolbar.R import mozilla.components.compose.browser.toolbar.R
import mozilla.components.compose.browser.toolbar.databinding.SearchSelectorBinding import mozilla.components.compose.browser.toolbar.databinding.SearchSelectorBinding
import mozilla.components.concept.menu.MenuController import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarEvent
import mozilla.components.compose.browser.toolbar.store.BrowserToolbarInteraction.BrowserToolbarMenu
import mozilla.components.concept.menu.candidate.DecorativeTextMenuCandidate
import mozilla.components.concept.menu.candidate.DrawableMenuIcon
import mozilla.components.concept.menu.candidate.TextMenuCandidate
import mozilla.components.ui.icons.R as iconsR import mozilla.components.ui.icons.R as iconsR
/** /**
@@ -97,25 +104,49 @@ fun SearchSelector(
/** /**
* Search selector toolbar action. * Search selector toolbar action.
* *
* @param onClick Invoked when the search selector is clicked.
* @param menu [MenuController] that will be used to create a menu when the search selector is
* clicked.
* @param icon [Drawable] to display in the search selector. * @param icon [Drawable] to display in the search selector.
* @param contentDescription The content description to use. * @param contentDescription The content description to use.
* @param menu The [BrowserToolbarMenu] to show when the search selector is clicked.
* @param onInteraction Invoked for user interactions with the menu items.
*/ */
@Composable @Composable
fun SearchSelector( fun SearchSelector(
onClick: () -> Unit, icon: Drawable,
menu: MenuController? = null, contentDescription: String,
icon: Drawable? = null, menu: BrowserToolbarMenu,
contentDescription: String? = null, onInteraction: (BrowserToolbarEvent) -> Unit,
) { ) {
val items = menu.items().mapNotNull {
if (it.icon == null && it.text != null) {
DecorativeTextMenuCandidate(
text = stringResource(it.text),
)
} else if (it.icon != null && it.text != null) {
TextMenuCandidate(
text = stringResource(it.text),
start = DrawableMenuIcon(
drawable = it.icon,
),
onClick = {
it.onClick?.let { onInteraction(it) }
},
)
} else {
null
}
}
val selector = remember(items) {
BrowserMenuController().apply {
submitList(items)
}
}
AndroidView( AndroidView(
factory = { context -> factory = { context ->
SearchSelector(context).apply { SearchSelector(context).apply {
setOnClickListener { setOnClickListener {
onClick() selector.show(anchor = it)
menu?.show(anchor = it)
} }
setIcon(icon, contentDescription) setIcon(icon, contentDescription)
@@ -150,8 +181,10 @@ private fun SearchSelectorPreview() {
) )
SearchSelector( SearchSelector(
onClick = {}, icon = getDrawable(LocalContext.current, iconsR.drawable.mozac_ic_search_24)!!,
icon = getDrawable(LocalContext.current, iconsR.drawable.mozac_ic_search_24), contentDescription = "Test",
menu = { emptyList() },
onInteraction = {},
) )
} }
} }

View File

@@ -54,6 +54,7 @@ fun BrowserToolbar(
editActionsEnd = uiState.editState.editActionsEnd, editActionsEnd = uiState.editState.editActionsEnd,
onUrlCommitted = { text -> onTextCommit(text) }, onUrlCommitted = { text -> onTextCommit(text) },
onUrlEdit = { text -> onTextEdit(text) }, onUrlEdit = { text -> onTextEdit(text) },
onInteraction = {},
) )
} else { } else {
BrowserDisplayToolbar( BrowserDisplayToolbar(
@@ -77,6 +78,7 @@ fun BrowserToolbar(
editActionsEnd = uiState.editState.editActionsEnd, editActionsEnd = uiState.editState.editActionsEnd,
onUrlCommitted = { text -> onTextCommit(text) }, onUrlCommitted = { text -> onTextCommit(text) },
onUrlEdit = { text -> onTextEdit(text) }, onUrlEdit = { text -> onTextEdit(text) },
onInteraction = {},
) )
} }