Repository: arkivanov/Decompose (kotlin)

Issue:
Add setItems extension functions for PagesNavigator
Two functions are needed:

```kotlin
fun <C : Any> PagesNavigator<C>.setItems(items: List<C>)
fun <C : Any> PagesNavigator<C>.setItems(items: (List<C>) -> List<C>)
```

Both functions should take care of the `selectedIndex` possibly going out of range.

Pull request:
Closes #876

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added new ways to update the list of pages in the navigator using the new `setItems` function, allowing for flexible replacement or transformation of pages.
- **Documentation**
  - Updated documentation to include details and usage examples for the new `setItems` function, along with improved formatting for existing navigation functions.
- **Tests**
  - Introduced new tests to verify the correct behavior of the `setItems` function, including handling of added and removed items and selected index adjustments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Patch:
diff --git a/decompose/api/android/decompose.api b/decompose/api/android/decompose.api
index 689fe1ed5..e26b1221f 100644
--- a/decompose/api/android/decompose.api
+++ b/decompose/api/android/decompose.api
@@ -360,6 +360,8 @@ public final class com/arkivanov/decompose/router/pages/PagesNavigatorExtKt {
 	public static synthetic fun selectNext$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
 	public static final fun selectPrev (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;)V
 	public static synthetic fun selectPrev$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun setItems (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function1;)V
+	public static final fun setItems (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
 }
 
 public final class com/arkivanov/decompose/router/pages/PagesWebNavigationKt {
diff --git a/decompose/api/decompose.klib.api b/decompose/api/decompose.klib.api
index 8c1263d47..3e1d48ea3 100644
--- a/decompose/api/decompose.klib.api
+++ b/decompose/api/decompose.klib.api
@@ -636,6 +636,8 @@ final inline fun (com.arkivanov.decompose.router.stack/StackNavigator<*>).com.ar
 final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.items/ItemsNavigator<#A>).com.arkivanov.decompose.router.items/setActiveItems(crossinline kotlin/Function1<kotlin.collections/Map<#A, com.arkivanov.decompose.router.items/Items.ActiveLifecycleState>, kotlin.collections/Map<#A, com.arkivanov.decompose.router.items/Items.ActiveLifecycleState>>) // com.arkivanov.decompose.router.items/setActiveItems|setActiveItems@com.arkivanov.decompose.router.items.ItemsNavigator<0:0>(kotlin.Function1<kotlin.collections.Map<0:0,com.arkivanov.decompose.router.items.Items.ActiveLifecycleState>,kotlin.collections.Map<0:0,com.arkivanov.decompose.router.items.Items.ActiveLifecycleState>>){0§<kotlin.Any>}[0]
 final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.items/ItemsNavigator<#A>).com.arkivanov.decompose.router.items/setActiveItems(crossinline kotlin/Function1<kotlin.collections/Map<#A, com.arkivanov.decompose.router.items/Items.ActiveLifecycleState>, kotlin.collections/Map<#A, com.arkivanov.decompose.router.items/Items.ActiveLifecycleState>>, crossinline kotlin/Function2<com.arkivanov.decompose.router.items/Items<#A>, com.arkivanov.decompose.router.items/Items<#A>, kotlin/Unit> = ...) // com.arkivanov.decompose.router.items/setActiveItems|setActiveItems@com.arkivanov.decompose.router.items.ItemsNavigator<0:0>(kotlin.Function1<kotlin.collections.Map<0:0,com.arkivanov.decompose.router.items.Items.ActiveLifecycleState>,kotlin.collections.Map<0:0,com.arkivanov.decompose.router.items.Items.ActiveLifecycleState>>;kotlin.Function2<com.arkivanov.decompose.router.items.Items<0:0>,com.arkivanov.decompose.router.items.Items<0:0>,kotlin.Unit>){0§<kotlin.Any>}[0]
 final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.items/ItemsNavigator<#A>).com.arkivanov.decompose.router.items/setItems(crossinline kotlin/Function1<kotlin.collections/List<#A>, kotlin.collections/List<#A>>, crossinline kotlin/Function2<com.arkivanov.decompose.router.items/Items<#A>, com.arkivanov.decompose.router.items/Items<#A>, kotlin/Unit> = ...) // com.arkivanov.decompose.router.items/setItems|setItems@com.arkivanov.decompose.router.items.ItemsNavigator<0:0>(kotlin.Function1<kotlin.collections.List<0:0>,kotlin.collections.List<0:0>>;kotlin.Function2<com.arkivanov.decompose.router.items.Items<0:0>,com.arkivanov.decompose.router.items.Items<0:0>,kotlin.Unit>){0§<kotlin.Any>}[0]
+final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.pages/PagesNavigator<#A>).com.arkivanov.decompose.router.pages/setItems(crossinline kotlin/Function1<kotlin.collections/List<#A>, kotlin.collections/List<#A>>) // com.arkivanov.decompose.router.pages/setItems|setItems@com.arkivanov.decompose.router.pages.PagesNavigator<0:0>(kotlin.Function1<kotlin.collections.List<0:0>,kotlin.collections.List<0:0>>){0§<kotlin.Any>}[0]
+final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.pages/PagesNavigator<#A>).com.arkivanov.decompose.router.pages/setItems(crossinline kotlin/Function1<kotlin.collections/List<#A>, kotlin.collections/List<#A>>, crossinline kotlin/Function2<com.arkivanov.decompose.router.pages/Pages<#A>, com.arkivanov.decompose.router.pages/Pages<#A>, kotlin/Unit>) // com.arkivanov.decompose.router.pages/setItems|setItems@com.arkivanov.decompose.router.pages.PagesNavigator<0:0>(kotlin.Function1<kotlin.collections.List<0:0>,kotlin.collections.List<0:0>>;kotlin.Function2<com.arkivanov.decompose.router.pages.Pages<0:0>,com.arkivanov.decompose.router.pages.Pages<0:0>,kotlin.Unit>){0§<kotlin.Any>}[0]
 final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.slot/SlotNavigator<#A>).com.arkivanov.decompose.router.slot/activate(#A, crossinline kotlin/Function0<kotlin/Unit> = ...) // com.arkivanov.decompose.router.slot/activate|activate@com.arkivanov.decompose.router.slot.SlotNavigator<0:0>(0:0;kotlin.Function0<kotlin.Unit>){0§<kotlin.Any>}[0]
 final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.stack/StackNavigator<#A>).com.arkivanov.decompose.router.stack/bringToFront(#A, crossinline kotlin/Function0<kotlin/Unit> = ...) // com.arkivanov.decompose.router.stack/bringToFront|bringToFront@com.arkivanov.decompose.router.stack.StackNavigator<0:0>(0:0;kotlin.Function0<kotlin.Unit>){0§<kotlin.Any>}[0]
 final inline fun <#A: kotlin/Any> (com.arkivanov.decompose.router.stack/StackNavigator<#A>).com.arkivanov.decompose.router.stack/pop(crossinline kotlin/Function1<kotlin/Boolean, kotlin/Unit> = ...) // com.arkivanov.decompose.router.stack/pop|pop@com.arkivanov.decompose.router.stack.StackNavigator<0:0>(kotlin.Function1<kotlin.Boolean,kotlin.Unit>){0§<kotlin.Any>}[0]
diff --git a/decompose/api/jvm/decompose.api b/decompose/api/jvm/decompose.api
index ee4eaf17e..25139921b 100644
--- a/decompose/api/jvm/decompose.api
+++ b/decompose/api/jvm/decompose.api
@@ -340,6 +340,8 @@ public final class com/arkivanov/decompose/router/pages/PagesNavigatorExtKt {
 	public static synthetic fun selectNext$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
 	public static final fun selectPrev (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;)V
 	public static synthetic fun selectPrev$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
+	public static final fun setItems (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function1;)V
+	public static final fun setItems (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V
 }
 
 public final class com/arkivanov/decompose/router/pages/PagesWebNavigationKt {
diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigatorExt.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigatorExt.kt
index fcb2b8688..67bedd3f0 100644
--- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigatorExt.kt
+++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesNavigatorExt.kt
@@ -1,5 +1,7 @@
 package com.arkivanov.decompose.router.pages
 
+import com.arkivanov.decompose.ExperimentalDecomposeApi
+
 /**
  * A convenience method for [PagesNavigator.navigate].
  */
@@ -103,6 +105,45 @@ fun <C : Any> PagesNavigator<C>.clear(onComplete: (newPages: Pages<C>, oldPages:
     )
 }
 
+/**
+ * Replaces the components with the provided list. The [Pages.selectedIndex] parameter
+ * is automatically coerced within the new list's range.
+ *
+ * @param items a transformer function from the current item list to a new one.
+ * See [Pages.items].
+ * @param onComplete called when the navigation is finished (either synchronously or asynchronously).
+ */
+@ExperimentalDecomposeApi
+inline fun <C : Any> PagesNavigator<C>.setItems(
+    crossinline items: (List<C>) -> List<C>,
+    crossinline onComplete: (newPages: Pages<C>, oldPages: Pages<C>) -> Unit,
+) {
+    navigate(
+        transformer = {
+            val newItems = items(it.items)
+            it.copy(
+                items = newItems,
+                selectedIndex = if (newItems.isNotEmpty()) it.selectedIndex.coerceIn(newItems.indices) else -1,
+            )
+        },
+        onComplete = { newPages, oldPages -> onComplete(newPages, oldPages) },
+    )
+}
+
+/**
+ * Replaces the components with the provided list. The [Pages.selectedIndex] parameter
+ * is automatically coerced within the new list's range.
+ *
+ * @param items a transformer function from the current item list to a new one.
+ * See [Pages.items].
+ */
+@ExperimentalDecomposeApi
+inline fun <C : Any> PagesNavigator<C>.setItems(
+    crossinline items: (List<C>) -> List<C>,
+) {
+    setItems(items = items, onComplete = { _, _ -> })
+}
+
 private inline fun <C : Any> Pages<C>.coerceSelectedIndex(circular: Boolean = false, update: (Int) -> Int): Pages<C> =
     if (items.isNotEmpty()) {
         var newIndex = update(selectedIndex)
diff --git a/docs/navigation/pages/navigation.md b/docs/navigation/pages/navigation.md
index 989164aa6..37e39a92f 100644
--- a/docs/navigation/pages/navigation.md
+++ b/docs/navigation/pages/navigation.md
@@ -30,106 +30,154 @@ There are `PagesNavigator` [extension functions](https://github.com/arkivanov/De
 
 Selects the next component. If the currently selected component is the last one, then depending on the [circular] parameter, either nothing happens or the first component is selected.
 
-```title="Before"
-1: [A, B*, C]
-2: [A, B, C*]
-3: [A, B, C*]
-```
-
-```
-1: navigation.selectNext()
-2: navigation.selectNext(circular = false)
-3: navigation.selectNext(circular = true)
-```
-
-```title="After"
-1: [A, B, C*]
-2: [A, B, C*]
-3: [A*, B, C]
-```
+!!! note "Illustration"
+
+    ```title="Before"
+    1: [A, B*, C]
+    2: [A, B, C*]
+    3: [A, B, C*]
+    ```
+    
+    ```
+    1: navigation.selectNext()
+    2: navigation.selectNext(circular = false)
+    3: navigation.selectNext(circular = true)
+    ```
+    
+    ```title="After"
+    1: [A, B, C*]
+    2: [A, B, C*]
+    3: [A*, B, C]
+    ```
 
 ### selectPrev
 
 elects the previous component. If the currently selected component is the first one, then depending on the [circular] parameter, either nothing happens or the last component is selected.
 
-```title="Before"
-1: [A, B*, C]
-2: [A*, B, C]
-3: [A*, B, C]
-```
-
-```
-1: navigation.selectPrev()
-2: navigation.selectPrev(circular = false)
-3: navigation.selectPrev(circular = true)
-```
-
-```title="After"
-1: [A*, B, C]
-2: [A*, B, C]
-3: [A, B, C*]
-```
+!!! note "Illustration"
+
+    ```title="Before"
+    1: [A, B*, C]
+    2: [A*, B, C]
+    3: [A*, B, C]
+    ```
+    
+    ```
+    1: navigation.selectPrev()
+    2: navigation.selectPrev(circular = false)
+    3: navigation.selectPrev(circular = true)
+    ```
+    
+    ```title="After"
+    1: [A*, B, C]
+    2: [A*, B, C]
+    3: [A, B, C*]
+    ```
 
 ### selectFirst
 
 Selects the first component.
 
-```title="Before"
-[A, B*, C]
-```
-
-```
-navigation.selectFirst()
-```
+!!! note "Illustration"
 
-```title="After"
-[A*, B, C]
-```
+    ```title="Before"
+    [A, B*, C]
+    ```
+    
+    ```
+    navigation.selectFirst()
+    ```
+    
+    ```title="After"
+    [A*, B, C]
+    ```
 
 ### selectLast
 
 Selects the last component.
 
-```title="Before"
-[A, B*, C]
-```
-
-```
-navigation.selectLast()
-```
+!!! note "Illustration"
 
-```title="After"
-[A, B, C*]
-```
+    ```title="Before"
+    [A, B*, C]
+    ```
+    
+    ```
+    navigation.selectLast()
+    ```
+    
+    ```title="After"
+    [A, B, C*]
+    ```
 
 ### select(index)
 
 Selects the component at the specified [index]. Throws [IllegalArgumentException] if the index is out of bounds.
 
-```title="Before"
-[A*, B, C]
-```
-
-```
-navigation.select(2)
-```
+!!! note "Illustration"
 
-```title="After"
-[A, B, C*]
-```
+    ```title="Before"
+    [A*, B, C]
+    ```
+    
+    ```
+    navigation.select(2)
+    ```
+    
+    ```title="After"
+    [A, B, C*]
+    ```
 
 ### clear
 
 Clears the current [Pages] state, i.e. removes all components.
 
-```title="Before"
-[A, B*, C]
-```
-
-```
-navigation.clear()
-```
-
-```title="After"
-[]
-```
+!!! note "Illustration"
+
+    ```title="Before"
+    [A, B*, C]
+    ```
+    
+    ```
+    navigation.clear()
+    ```
+    
+    ```title="After"
+    []
+    ```
+
+### setItems
+
+!!! note
+
+    Available since version `3.4.0-alpha02`.
+
+Replaces the components with the provided list. The `selectedIndex` parameter is automatically coerced within the new list's range.
+
+!!! note "Illustration 1"
+
+    ```title="Before"
+    [A, B, C*]
+    ```
+    
+    ```
+    navigation.setItems { it + listOf(D) }
+    ```
+    
+    ```title="After"
+    [A, B, C*, D]
+    ```
+
+!!! note "Illustration 2"
+
+    ```title="Before"
+    [A, B, C*]
+    ```
+    
+    ```
+    navigation.setItems { it - C }
+    ```
+    
+    ```title="After"
+    [A, B*]
+    ```


Test patch:
diff --git a/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSetItemsTest.kt b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSetItemsTest.kt
new file mode 100644
index 000000000..7bc42dc41
--- /dev/null
+++ b/decompose/src/commonTest/kotlin/com/arkivanov/decompose/router/pages/ChildPagesSetItemsTest.kt
@@ -0,0 +1,47 @@
+package com.arkivanov.decompose.router.pages
+
+import com.arkivanov.decompose.TestComponentContext
+import com.arkivanov.decompose.value.getValue
+import kotlin.test.Test
+
+@Suppress("TestFunctionName")
+class ChildPagesSetItemsTest : BaseChildPagesTest() {
+
+    private val ctx = TestComponentContext()
+
+    @Test
+    fun WHEN_setItems_with_new_items_THEN_items_added() {
+        val pages by ctx.childPages(initialPages = Pages(items = listOf(0, 1, 2), selectedIndex = 2))
+
+        navigation.setItems { it + listOf(3, 4) }
+
+        pages.assertPages(selectedIndex = 2, size = 5)
+    }
+
+    @Test
+    fun WHEN_setItems_with_items_removed_from_end_THEN_items_removed_and_selected_index_coerced() {
+        val pages by ctx.childPages(initialPages = Pages(items = listOf(0, 1, 2, 3, 4), selectedIndex = 4))
+
+        navigation.setItems { it.dropLast(2) }
+
+        pages.assertPages(selectedIndex = 2, size = 3)
+    }
+
+    @Test
+    fun WHEN_setItems_with_items_removed_from_start_THEN_items_removed() {
+        val pages by ctx.childPages(initialPages = Pages(items = listOf(0, 1, 2, 3, 4), selectedIndex = 0))
+
+        navigation.setItems { it.drop(2) }
+
+        pages.assertPages(ids = listOf(2, 3, 4), selectedIndex = 0)
+    }
+
+    @Test
+    fun WHEN_setItems_with_all_items_removed_THEN_items_removed() {
+        val pages by ctx.childPages(initialPages = Pages(items = listOf(0, 1, 2), selectedIndex = 0))
+
+        navigation.setItems { emptyList() }
+
+        pages.assertPages(ids = emptyList(), selectedIndex = -1)
+    }
+}