commit 21de203cc48e5a6bf6b65370928138f5c3f1a799
parent 738780f1f83bcfe282d30051b08c666d5b778353
Author: Cathy Lu <calu@mozilla.com>
Date: Thu, 18 Dec 2025 17:18:55 +0000
Bug 1994286 - Add tab search results section r=android-reviewers,android-l10n-reviewers,007,flod
Differential Revision: https://phabricator.services.mozilla.com/D276720
Diffstat:
5 files changed, 258 insertions(+), 13 deletions(-)
diff --git a/mobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/searchbar/SearchBar.kt b/mobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/searchbar/SearchBar.kt
@@ -8,6 +8,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -41,6 +42,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import mozilla.components.compose.base.R
+import mozilla.components.compose.base.annotation.FlexibleWindowLightDarkPreview
import mozilla.components.compose.base.theme.AcornTheme
import androidx.compose.material3.SearchBar as M3SearchBar
import androidx.compose.material3.TopSearchBar as M3TopSearchBar
@@ -222,6 +224,7 @@ private fun SearchBarInputField(
LocalTextStyle provides AcornTheme.typography.body1,
) {
SearchBarDefaults.InputField(
+ modifier = Modifier.fillMaxWidth(),
query = query,
onQueryChange = onQueryChange,
onSearch = onSearch,
@@ -378,7 +381,7 @@ private fun SearchBarPreview(
}
@OptIn(ExperimentalMaterial3Api::class)
-@PreviewLightDark
+@FlexibleWindowLightDarkPreview
@Composable
private fun TopSearchBarPreview() {
val state = rememberSearchBarState()
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/redux/state/TabSearchState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/redux/state/TabSearchState.kt
@@ -15,4 +15,10 @@ import mozilla.components.browser.state.state.TabSessionState
data class TabSearchState(
val query: String = "",
val searchResults: List<TabSessionState> = emptyList(),
-)
+) {
+ /**
+ * Gets whether or not to show there are no search results.
+ */
+ val showNoResults: Boolean
+ get() = query.isNotEmpty() && searchResults.isEmpty()
+}
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabsearch/TabSearchScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabsearch/TabSearchScreen.kt
@@ -4,11 +4,24 @@
package org.mozilla.fenix.tabstray.ui.tabsearch
-import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSearchBarState
@@ -18,24 +31,40 @@ import androidx.compose.runtime.getValue
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.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.dp
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.compose.base.annotation.FlexibleWindowLightDarkPreview
import mozilla.components.compose.base.searchbar.TopSearchBar
import mozilla.components.lib.state.ext.observeAsState
import org.mozilla.fenix.R
+import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.tabstray.TabSearchAction
import org.mozilla.fenix.tabstray.TabsTrayAction
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
+import org.mozilla.fenix.tabstray.ext.toDisplayTitle
+import org.mozilla.fenix.tabstray.redux.state.TabSearchState
+import org.mozilla.fenix.tabstray.ui.tabitems.BasicTabListItem
+import org.mozilla.fenix.tabstray.ui.tabpage.EmptyTabPage
import org.mozilla.fenix.theme.FirefoxTheme
import mozilla.components.ui.icons.R as iconsR
+private val SearchResultsCornerRadius = 12.dp
+private val SearchResultsPadding = 16.dp
+
/**
* The top-level Composable for the Tab Search feature within the Tab Manager.
*
@@ -46,7 +75,7 @@ import mozilla.components.ui.icons.R as iconsR
fun TabSearchScreen(
store: TabsTrayStore,
) {
- val state by store.observeAsState(store.state) { it }
+ val state by store.observeAsState(store.state.tabSearchState) { it.tabSearchState }
val searchBarState = rememberSearchBarState()
var expanded by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
@@ -57,8 +86,10 @@ fun TabSearchScreen(
topBar = {
TopSearchBar(
state = searchBarState,
- modifier = Modifier.focusRequester(focusRequester),
- query = state.tabSearchState.query,
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .padding(horizontal = 8.dp),
+ query = state.query,
onQueryChange = { store.dispatch(TabSearchAction.SearchQueryChanged(it)) },
onSearch = { submitted -> store.dispatch(TabSearchAction.SearchQueryChanged(submitted)) },
expanded = expanded,
@@ -83,26 +114,193 @@ fun TabSearchScreen(
)
},
) { innerPadding ->
- Box(modifier = Modifier.padding(innerPadding)) {
- // TODO Bug 1994286: will add results UI
+ Column(
+ modifier = Modifier.padding(innerPadding).fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (state.showNoResults) {
+ EmptyTabSearchResults(
+ modifier = Modifier
+ .fillMaxSize(),
+ )
+ } else {
+ TabSearchResults(
+ searchResults = state.searchResults,
+ modifier = Modifier
+ .padding(horizontal = SearchResultsPadding),
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Composable for the tab search screen results.
+ *
+ * @param searchResults List of search results.
+ * @param modifier The [Modifier] to be applied.
+ */
+@Composable
+private fun TabSearchResults(
+ searchResults: List<TabSessionState>,
+ modifier: Modifier = Modifier,
+) {
+ val lastIndex = searchResults.lastIndex
+ val maxWidth = FirefoxTheme.layout.size.containerMaxWidth
+
+ LazyColumn(
+ modifier = modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(vertical = SearchResultsPadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ itemsIndexed(
+ items = searchResults,
+ key = { _, tab -> tab.id },
+ ) { index, tab ->
+ val tabUrl = tab.content.url.toShortUrl()
+ val faviconPainter = tab.content.icon?.run {
+ prepareToDraw()
+ BitmapPainter(asImageBitmap())
+ }
+
+ val itemShape = when {
+ lastIndex == 0 ->
+ RoundedCornerShape(SearchResultsCornerRadius)
+ index == 0 ->
+ RoundedCornerShape(
+ topStart = SearchResultsCornerRadius,
+ topEnd = SearchResultsCornerRadius,
+ )
+ index == lastIndex ->
+ RoundedCornerShape(
+ bottomStart = SearchResultsCornerRadius,
+ bottomEnd = SearchResultsCornerRadius,
+ )
+ else ->
+ RoundedCornerShape(0.dp)
+ }
+
+ BasicTabListItem(
+ title = tab.toDisplayTitle(),
+ url = tabUrl,
+ modifier = Modifier
+ .clip(itemShape)
+ .widthIn(max = maxWidth)
+ .background(MaterialTheme.colorScheme.surfaceContainerLowest),
+ faviconPainter = faviconPainter,
+ onClick = {
+ // TODO (Bug 2005595): Handle search result clicks
+ },
+ )
+
+ if (index < lastIndex) {
+ HorizontalDivider(
+ modifier = Modifier.widthIn(max = maxWidth),
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Composable for the tab search screen when there are no results.
+ *
+ * @param modifier The [Modifier] to be applied.
+ */
+@Composable
+private fun EmptyTabSearchResults(
+ modifier: Modifier = Modifier,
+) {
+ EmptyTabPage(
+ modifier = modifier,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Image(
+ modifier = Modifier.size(77.dp),
+ painter = painterResource(R.drawable.fox_exclamation_alert),
+ contentDescription = null,
+ )
+
+ Text(
+ text = stringResource(R.string.tab_manager_no_search_results),
+ textAlign = TextAlign.Center,
+ style = FirefoxTheme.typography.body2,
+ )
+
+ Text(
+ text = stringResource(R.string.tab_manager_no_search_results_additional_text),
+ textAlign = TextAlign.Center,
+ style = FirefoxTheme.typography.body2,
+ )
}
}
}
private class TabSearchParameterProvider : PreviewParameterProvider<TabsTrayState> {
+ private val searchResults = listOf(
+ createTab(
+ url = "mozilla.org",
+ id = "1",
+ title = "Mozilla",
+ ),
+ createTab(
+ url = "maps.google.com",
+ id = "2",
+ title = "Google Maps",
+ ),
+ createTab(
+ url = "google.com/maps/place/Mozilla+Toronto/@43.6472856,-79.3944129,17z/",
+ id = "3",
+ title = "Long Google Maps URL",
+ ),
+ )
+
+ private val manySearchResults = buildList {
+ repeat(4) { index ->
+ searchResults.forEach { tab ->
+ add(tab.copy(id = "${tab.id}-$index"))
+ }
+ }
+ }
+
override val values = sequenceOf(
TabsTrayState(),
+ TabsTrayState(
+ tabSearchState = TabSearchState(
+ query = "m",
+ searchResults = searchResults,
+ ),
+ ),
+ TabsTrayState(
+ tabSearchState = TabSearchState(
+ query = "firefox",
+ searchResults = emptyList(),
+ ),
+ ),
+ TabsTrayState(
+ tabSearchState = TabSearchState(
+ query = "m",
+ searchResults = manySearchResults,
+ ),
+ ),
)
}
-@PreviewLightDark
+/**
+ * Preview for the tab search screen.
+ */
+@FlexibleWindowLightDarkPreview
@Composable
private fun TabSearchScreenPreview(
@PreviewParameter(TabSearchParameterProvider::class) state: TabsTrayState,
) {
- val store = remember { TabsTrayStore(initialState = state) }
-
+ val store = remember {
+ TabsTrayStore(initialState = state)
+ }
FirefoxTheme {
- TabSearchScreen(store)
+ TabSearchScreen(store = store)
}
}
diff --git a/mobile/android/fenix/app/src/main/res/values/strings.xml b/mobile/android/fenix/app/src/main/res/values/strings.xml
@@ -1467,6 +1467,10 @@
<string name="remove_tab_from_collection">Remove tab from collection</string>
<!-- Text for button to enter multiselect mode in tabs tray -->
<string name="tabs_tray_select_tabs">Select tabs</string>
+ <!-- Text for tab manager search page when there are no results. -->
+ <string name="tab_manager_no_search_results">No matches found.</string>
+ <!-- Additional text for tab manager search page when there are no results. -->
+ <string name="tab_manager_no_search_results_additional_text">Try another search!</string>
<!-- Content description (not visible, for screen readers etc.): Close tab button. Closes the current session when pressed -->
<string name="close_tab">Close tab</string>
<!-- Content description (not visible, for screen readers etc.): Close tab <title> button. %s is the tab title -->
diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/redux/state/TabSearchStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/redux/state/TabSearchStateTest.kt
@@ -5,7 +5,10 @@
package org.mozilla.fenix.tabstray.redux.state
import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.mockk.mockk
+import mozilla.components.browser.state.state.createTab
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@@ -19,5 +22,36 @@ class TabSearchStateTest {
assertEquals("", state.query)
assertTrue(state.searchResults.isEmpty())
+ assertFalse(state.showNoResults)
+ }
+
+ @Test
+ fun `WHEN query is empty AND searchResults is empty THEN showNoResults is false`() {
+ val state = TabSearchState(
+ query = "",
+ searchResults = emptyList(),
+ )
+
+ assertFalse(state.showNoResults)
+ }
+
+ @Test
+ fun `WHEN query is not empty AND searchResults is empty THEN showNoResults is true`() {
+ val state = TabSearchState(
+ query = "Mozilla",
+ searchResults = emptyList(),
+ )
+
+ assertTrue(state.showNoResults)
+ }
+
+ @Test
+ fun `WHEN query is not empty AND searchResults is not empty THEN showNoResults is false`() {
+ val state = TabSearchState(
+ query = "Mozilla",
+ searchResults = listOf(createTab("mozilla.org", id = "mozilla")),
+ )
+
+ assertFalse(state.showNoResults)
}
}