Skip to content

Commit a3ab3cf

Browse files
committed
Make disclosure expansion controlled
Replace DisclosureState with expanded and onExpandedChange parameters so callers can choose their own state API. Add common tests for the callback contract and panel visibility.
1 parent 552f8b6 commit a3ab3cf

4 files changed

Lines changed: 131 additions & 24 deletions

File tree

composeunstyled-disclosure/build.gradle.kts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,28 @@ kotlin {
8888
implementation(projects.composeunstyledButton)
8989
}
9090
}
91+
92+
commonTest.dependencies {
93+
implementation(kotlin("test"))
94+
95+
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
96+
implementation(libs.compose.ui.test)
97+
}
98+
99+
androidInstrumentedTest.dependencies {
100+
implementation(libs.androidx.compose.test)
101+
implementation(libs.androidx.compose.test.manifest)
102+
implementation(libs.androidx.espresso)
103+
}
104+
105+
val jvmTest by getting
106+
107+
jvmTest.dependencies {
108+
implementation(libs.compose.ui.test.junit4)
109+
implementation(compose.desktop.currentOs) {
110+
exclude(group = "org.jetbrains.compose.material", module = "material")
111+
}
112+
}
91113
}
92114
}
93115

composeunstyled-disclosure/src/commonMain/kotlin/com/composeunstyled/Disclosure.kt

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,7 @@ import androidx.compose.foundation.layout.Column
3737
import androidx.compose.foundation.layout.PaddingValues
3838
import androidx.compose.runtime.Composable
3939
import androidx.compose.runtime.Stable
40-
import androidx.compose.runtime.getValue
41-
import androidx.compose.runtime.mutableStateOf
4240
import androidx.compose.runtime.remember
43-
import androidx.compose.runtime.setValue
4441
import androidx.compose.ui.Alignment
4542
import androidx.compose.ui.Modifier
4643
import androidx.compose.ui.graphics.Color
@@ -58,27 +55,29 @@ private val DisappearInstantly: ExitTransition = fadeOut(animationSpec = tween(d
5855
private val NoPadding = PaddingValues(0.dp)
5956

6057
@Stable
61-
class DisclosureState(expanded: Boolean = false) {
62-
var expanded: Boolean by mutableStateOf(expanded)
63-
}
58+
class DisclosureScope internal constructor(
59+
val expanded: Boolean,
60+
internal val onExpandedChange: (Boolean) -> Unit,
61+
)
6462

6563
@Composable
66-
fun rememberDisclosureState(initiallyExpanded: Boolean = false): DisclosureState {
67-
return remember { DisclosureState(initiallyExpanded) }
68-
}
69-
70-
@Stable
71-
class DisclosureScope internal constructor(state: DisclosureState) {
72-
internal var state by mutableStateOf(state)
64+
private fun rememberDisclosureScope(
65+
expanded: Boolean,
66+
onExpandedChange: (Boolean) -> Unit,
67+
): DisclosureScope {
68+
return remember(expanded, onExpandedChange) {
69+
DisclosureScope(expanded, onExpandedChange)
70+
}
7371
}
7472

7573
@Composable
7674
fun UnstyledDisclosure(
77-
state: DisclosureState = rememberDisclosureState(),
75+
expanded: Boolean,
76+
onExpandedChange: (Boolean) -> Unit,
7877
modifier: Modifier = Modifier,
7978
content: @Composable DisclosureScope.() -> Unit,
8079
) {
81-
val scope = remember { DisclosureScope(state) }
80+
val scope = rememberDisclosureScope(expanded, onExpandedChange)
8281

8382
Column(modifier) {
8483
scope.content()
@@ -103,19 +102,19 @@ fun DisclosureScope.UnstyledDisclosureHeading(
103102
UnstyledButton(
104103
modifier = modifier.semantics {
105104
heading()
106-
if (state.expanded) {
105+
if (expanded) {
107106
collapse {
108-
state.expanded = false
107+
onExpandedChange(false)
109108
true
110109
}
111110
} else {
112111
expand {
113-
state.expanded = true
112+
onExpandedChange(true)
114113
true
115114
}
116115
}
117116
},
118-
onClick = { state.expanded = state.expanded.not() },
117+
onClick = { onExpandedChange(expanded.not()) },
119118
interactionSource = interactionSource,
120119
indication = indication,
121120
enabled = enabled,
@@ -174,7 +173,7 @@ fun DisclosureScope.UnstyledDisclosurePanel(
174173
) {
175174
AnimatedVisibility(
176175
modifier = modifier,
177-
visible = state.expanded,
176+
visible = expanded,
178177
enter = enter,
179178
exit = exit,
180179
) {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright (c) 2026 Composable Horizons
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
package com.composeunstyled
23+
24+
import androidx.compose.foundation.text.BasicText
25+
import androidx.compose.runtime.getValue
26+
import androidx.compose.runtime.mutableStateOf
27+
import androidx.compose.runtime.setValue
28+
import androidx.compose.ui.Modifier
29+
import androidx.compose.ui.platform.testTag
30+
import androidx.compose.ui.test.onNodeWithTag
31+
import androidx.compose.ui.test.performClick
32+
import androidx.compose.ui.test.runComposeUiTest
33+
import kotlin.test.Test
34+
import kotlin.test.assertEquals
35+
36+
class DisclosureTest {
37+
@Test
38+
fun callsOnExpandedChangeWithNextState() = runComposeUiTest {
39+
var nextValue: Boolean? = null
40+
41+
setContent {
42+
UnstyledDisclosure(
43+
expanded = false,
44+
onExpandedChange = { nextValue = it },
45+
) {
46+
UnstyledDisclosureHeading(Modifier.testTag("heading")) {
47+
BasicText("Heading")
48+
}
49+
}
50+
}
51+
52+
onNodeWithTag("heading").performClick()
53+
54+
assertEquals(true, nextValue)
55+
}
56+
57+
@Test
58+
fun showsPanelWhenExpanded() = runComposeUiTest {
59+
var expanded by mutableStateOf(false)
60+
61+
setContent {
62+
UnstyledDisclosure(
63+
expanded = expanded,
64+
onExpandedChange = { expanded = it },
65+
) {
66+
UnstyledDisclosureHeading(Modifier.testTag("heading")) {
67+
BasicText("Heading")
68+
}
69+
UnstyledDisclosurePanel(Modifier.testTag("panel")) {
70+
BasicText("Panel")
71+
}
72+
}
73+
}
74+
75+
onNodeWithTag("panel").assertDoesNotExist()
76+
77+
onNodeWithTag("heading").performClick()
78+
79+
onNodeWithTag("panel").assertExists()
80+
}
81+
}

demo/src/commonMain/kotlin/DisclosureDemo.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
4040
import androidx.compose.material3.Text
4141
import androidx.compose.runtime.Composable
4242
import androidx.compose.runtime.getValue
43+
import androidx.compose.runtime.mutableStateOf
44+
import androidx.compose.runtime.remember
45+
import androidx.compose.runtime.setValue
4346
import androidx.compose.ui.Alignment
4447
import androidx.compose.ui.Modifier
4548
import androidx.compose.ui.draw.alpha
@@ -57,7 +60,6 @@ import com.composeunstyled.UnstyledDisclosureHeading
5760
import com.composeunstyled.UnstyledDisclosurePanel
5861
import com.composeunstyled.UnstyledIcon
5962
import com.composeunstyled.UnstyledSeparator
60-
import com.composeunstyled.rememberDisclosureState
6163

6264
@Composable
6365
fun DisclosureDemo() {
@@ -95,15 +97,18 @@ fun DisclosureDemo() {
9597
UnstyledSeparator(color = Color.Black.copy(0.2f))
9698
}
9799

98-
val state = rememberDisclosureState(initiallyExpanded = i == 0)
99-
UnstyledDisclosure(state = state) {
100+
var expanded by remember { mutableStateOf(i == 0) }
101+
UnstyledDisclosure(
102+
expanded = expanded,
103+
onExpandedChange = { expanded = it },
104+
) {
100105
UnstyledDisclosureHeading(
101106
contentPadding = PaddingValues(vertical = 12.dp, horizontal = 16.dp),
102107
indication = LocalIndication.current,
103108
) {
104109
Text(faq.question, modifier = Modifier.weight(1f))
105110

106-
val degrees by animateFloatAsState(if (state.expanded) -180f else 0f, tween())
111+
val degrees by animateFloatAsState(if (expanded) -180f else 0f, tween())
107112
UnstyledIcon(
108113
imageVector = Lucide.ChevronDown,
109114
contentDescription = null,

0 commit comments

Comments
 (0)