diff --git a/README.ko.md b/README.ko.md index a17b4298..4791d466 100644 --- a/README.ko.md +++ b/README.ko.md @@ -120,7 +120,7 @@ Source-Available License 1.0을 따른다. compatibility canary는 비공개· 때만 만든다 (명세 §8): `@devslab/dds-tokens` (✅ 2026-08-13) → `dds-css` v1 인벤토리 (Core 6종 ✅ 2026-08-13, +13종 ✅ 2026-08-15) → AskLinq 적용 → `dds-icons` (✅ 2026-08-15) → `dds-solid` + `site-kit` → 안드로이드 네이티브 값·테마·모션 -(0.6.0, 2026-11-12, Maven Central — 테마·모션·부품·아이콘). 0.7.0(2026-11-13, Maven Central)에서 화면 틀과 `dds-compose-testing` 꾸러미가 생겼다(D-052). RN·Ionic 패키지는 그 제품이 생길 때. +(0.6.0, 2026-11-12, Maven Central — 테마·모션·부품·아이콘). 0.7.0(2026-11-13, Maven Central)에서 화면 틀과 `dds-compose-testing` 꾸러미가 생겼다(D-052). 0.8.0(2026-11-13)에서 `DdsSegmentedControl`이 생겼다. RN·Ionic 패키지는 그 제품이 생길 때. ## 입력·선택·공유 달력 확장 diff --git a/README.md b/README.md index fdc57aa2..2649ba59 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Priorities live in [`docs/backlog.md`](docs/backlog.md). Packages are created only when a consumer exists (spec §8): `@devslab/dds-tokens` (✅ 2026-08-13) → `dds-css` v1 inventory (Core 6 ✅ 2026-08-13, +13 ✅ 2026-08-15) → AskLinq adoption → `dds-icons` (✅ 2026-08-15) → `dds-solid` + `site-kit` → native Android values, theme and motion -(0.6.0, 2026-11-12, on Maven Central — theme, motion, components, icons). 0.7.0 (2026-11-13, on Maven Central) adds the screen frame and the `dds-compose-testing` artifact (D-052). RN and Ionic packages only when such a product exists. +(0.6.0, 2026-11-12, on Maven Central — theme, motion, components, icons). 0.7.0 (2026-11-13, on Maven Central) adds the screen frame and the `dds-compose-testing` artifact (D-052). 0.8.0 (2026-11-13) adds `DdsSegmentedControl`. RN and Ionic packages only when such a product exists. ## Input, choice and shared calendar additions diff --git a/android/CHANGELOG.ko.md b/android/CHANGELOG.ko.md index 0fd9ea2f..71d9d782 100644 --- a/android/CHANGELOG.ko.md +++ b/android/CHANGELOG.ko.md @@ -5,6 +5,17 @@ `kr.devslab:dds-android-tokens`, `kr.devslab:dds-compose`, 그리고 0.7.0부터 `kr.devslab:dds-compose-testing`은 한 버전을 쓰고, 웹 패키지와는 버전이 따로다(D-048). 릴리스는 `android-v<버전>` 태그로 낸다. +## 0.8.0 — 2026-11-13 + +**새로 생긴 것** + +- **`DdsSegmentedControl`, `DdsSegment`** — 몇 개 안 되는 보기 중 하나를 고르는 부품. 웹 SegmentedControl의 터치 + 크기 그대로다: `bg.subtle` 트랙 + 1dp `border.strong` 선 + 둥글기 8, 안쪽과 칸 사이 2dp, 고른 칸은 `bg.default` 면 + + 1dp 선 + 1단계 그림자 + 굵기 600. 트랙 50dp(터치 48 + 선), 칸의 면 44dp, 큰 글자 모드는 터치 56. 내용 폭(기본)은 굵은 + 폭을 늘 잡아 두어 골라도 이웃이 움직이지 않고, 넘치면 트랙 안에서 넘긴다. `fullWidth`는 폭을 똑같이 나누고 긴 라벨은 + 말줄임표. 라디오 그룹으로 읽힌다. `label`/`contentDescription`, `error`(선 색 + 문장 + 낭독), `readOnly`, `enabled`, 칸별 + `enabled`. 가이드 §12. 첫 소비자는 Qooroo의 명함 종류 토글이다 — 앱의 사본 `DdsStyleSegmented`를 이것으로 바꾼다. + ## 0.7.0 — 2026-11-13 화면 틀(D-052). 정산나우가 펼친 폰에서 찾았다. 화면마다 시작선이 달랐고(187, 147, 127dp, 또는 끝에서 diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index fc51cdb8..9ed55b4d 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -5,6 +5,19 @@ `kr.devslab:dds-android-tokens`, `kr.devslab:dds-compose` and (from 0.7.0) `kr.devslab:dds-compose-testing` share one version, separate from the web packages (D-048). Releases are cut by an `android-v` tag. +## 0.8.0 — 2026-11-13 + +**New** + +- **`DdsSegmentedControl`, `DdsSegment`** — one choice among a few visible options. The web SegmentedControl at its + touch size: a `bg.subtle` track with a 1dp `border.strong` edge and radius 8, 2dp inside and between segments, the + chosen one a `bg.default` face with a 1dp edge, a level-1 shadow and weight 600. The track is 50dp (48 of touch + the + edge), a face 44dp, and the touch height is 56 in large text mode. Content width (the default) always holds the bold + width, so choosing never moves a neighbour, and too many segments scroll inside the track; `fullWidth` shares the + width equally and ends a long label in an ellipsis. Read as a radio group. `label`/`contentDescription`, `error` + (edge color + a sentence + spoken), `readOnly`, `enabled`, and `enabled` per segment. Guide §12. The first consumer is + Qooroo's card-type toggle, whose in-app copy `DdsStyleSegmented` this replaces. + ## 0.7.0 — 2026-11-13 The screen frame (D-052), from JeongsanNow on an unfolded phone: screens started on diff --git a/android/README.ko.md b/android/README.ko.md index 2511bb21..5e37fafa 100644 --- a/android/README.ko.md +++ b/android/README.ko.md @@ -22,12 +22,12 @@ dependencies { } ``` -0.7.0에서 화면 틀(가이드 §13)과 `dds-compose-testing` 꾸러미가 생겼다. +0.7.0에서 화면 틀(가이드 §13)과 `dds-compose-testing` 꾸러미가, 0.8.0에서 `DdsSegmentedControl`(가이드 §12)이 생겼다. ```kotlin dependencies { - implementation("kr.devslab:dds-compose:0.7.0") - androidTestImplementation("kr.devslab:dds-compose-testing:0.7.0") + implementation("kr.devslab:dds-compose:0.8.0") + androidTestImplementation("kr.devslab:dds-compose-testing:0.8.0") } ``` diff --git a/android/README.md b/android/README.md index e361fb28..5f7bfb46 100644 --- a/android/README.md +++ b/android/README.md @@ -22,12 +22,12 @@ dependencies { } ``` -0.7.0 adds the screen frame (guide §13) and the `dds-compose-testing` artifact: +0.7.0 adds the screen frame (guide §13) and the `dds-compose-testing` artifact, and 0.8.0 `DdsSegmentedControl` (guide §12): ```kotlin dependencies { - implementation("kr.devslab:dds-compose:0.7.0") - androidTestImplementation("kr.devslab:dds-compose-testing:0.7.0") + implementation("kr.devslab:dds-compose:0.8.0") + androidTestImplementation("kr.devslab:dds-compose-testing:0.8.0") } ``` diff --git a/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/Pages.kt b/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/Pages.kt index dd44ec85..5da31d2d 100644 --- a/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/Pages.kt +++ b/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/Pages.kt @@ -59,6 +59,8 @@ import kr.devslab.dds.compose.component.DdsMenuDivider import kr.devslab.dds.compose.component.DdsMenuItem import kr.devslab.dds.compose.component.DdsSearchField import kr.devslab.dds.compose.component.DdsSelectField +import kr.devslab.dds.compose.component.DdsSegment +import kr.devslab.dds.compose.component.DdsSegmentedControl import kr.devslab.dds.compose.component.DdsTabs import kr.devslab.dds.compose.component.DdsToastHost import kr.devslab.dds.compose.component.DdsValueRow @@ -343,6 +345,8 @@ fun PartsPage() { var query by rememberSaveable { mutableStateOf("") } var bank by rememberSaveable { mutableStateOf(null) } var view by rememberSaveable { mutableStateOf(0) } + var kind by rememberSaveable { mutableStateOf("person") } + var period by rememberSaveable { mutableStateOf("week") } var menu by remember { mutableStateOf(false) } var sheet by rememberSaveable { mutableStateOf(false) } val toasts = remember { SnackbarHostState() } @@ -429,6 +433,22 @@ fun PartsPage() { } } } + DdsSection( + "Segmented control", + description = "One choice among a few. The chosen width is kept in every state, so nothing shifts.", + ) { + val kinds = listOf(DdsSegment("person", "개인"), DdsSegment("shop", "비즈니스")) + DdsSegmentedControl(kinds, kind, { kind = it }, label = "명함 종류", fullWidth = true) + DdsSegmentedControl( + listOf(DdsSegment("day", "일"), DdsSegment("week", "주"), DdsSegment("month", "월"), DdsSegment("year", "연", enabled = false)), + period, + { period = it }, + contentDescription = "기간", + ) + DdsSegmentedControl(kinds, "shop", {}, contentDescription = "읽기 전용", readOnly = true) + DdsSegmentedControl(kinds, "person", {}, contentDescription = "비활성", enabled = false) + DdsSegmentedControl(kinds, "", {}, label = "필수 선택", fullWidth = true, error = "종류를 고르세요") + } DdsSection("Choices") { DdsCard { DdsSwitch(notify, { notify = it }, label = "알림 받기", description = "서명이 끝나면 알려 드립니다") diff --git a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/SegmentedOnDeviceTest.kt b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/SegmentedOnDeviceTest.kt new file mode 100644 index 00000000..f57768b8 --- /dev/null +++ b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/SegmentedOnDeviceTest.kt @@ -0,0 +1,342 @@ +package kr.devslab.dds.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PixelMap +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher.Companion.expectValue +import androidx.compose.ui.test.SemanticsMatcher.Companion.keyIsDefined +import androidx.compose.ui.test.SemanticsMatcher.Companion.keyNotDefined +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kr.devslab.dds.compose.component.DdsSegment +import kr.devslab.dds.compose.component.DdsSegmentedControl +import kr.devslab.dds.compose.theme.DdsColors +import kr.devslab.dds.compose.theme.DdsTheme +import kr.devslab.dds.tokens.DdsBorder +import kr.devslab.dds.tokens.DdsSpace +import kr.devslab.dds.tokens.DdsTouch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +/** + * The segmented control: its sizes, that choosing never moves anything, what + * its edges and faces are made of (read from the pixels a device draws), and + * what a screen reader hears. + * + * Run on an emulator or a phone (CI has neither): + * ANDROID_SERIAL= ./gradlew :dds-compose:connectedDebugAndroidTest + */ +class SegmentedOnDeviceTest { + @get:Rule + val rule = createComposeRule() + + private val green = Color.Green + private lateinit var colors: DdsColors + + private val kinds = listOf(DdsSegment("person", "개인"), DdsSegment("shop", "비즈니스")) + + /** The control on a page of a green no theme has, so the page and a face cannot be mistaken. */ + @OptIn(ExperimentalMaterial3Api::class) + private fun show(dark: Boolean = false, largeText: Boolean = false, width: Int? = null, content: @Composable () -> Unit) { + rule.setContent { + DdsTheme(darkTheme = dark, largeText = largeText) { + colors = DdsTheme.colors + CompositionLocalProvider(LocalRippleConfiguration provides null) { + Box(Modifier.background(green).padding(16.dp)) { + Box(if (width != null) Modifier.width(width.dp) else Modifier) { content() } + } + } + } + } + rule.waitForIdle() + } + + private fun bounds(tag: String): Rect = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + + private fun segment(label: String): Rect = rule.onNodeWithText(label).fetchSemanticsNode().boundsInRoot + + private val oneDp: Float get() = with(rule.density) { 1.dp.toPx() } + + private fun px(dp: Int): Float = dp * oneDp + + private fun assertPx(message: String, expectedDp: Int, actualPx: Float) = + assertEquals(message, px(expectedDp), actualPx, 1f) + + private fun textLayout(label: String): TextLayoutResult { + val results = mutableListOf() + // The unseen bold copy that holds the width has no semantics, so this is the drawn label. + val node = rule.onNodeWithText(label, useUnmergedTree = true).fetchSemanticsNode() + node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results) + return results.single() + } + + // --- size ------------------------------------------------------------------ + + @Test + fun theTrackIsTheTouchHeightPlusItsLineAndASegmentIsTheTouchHeight() { + show { DdsSegmentedControl(kinds, "person", {}, Modifier.testTag("control"), contentDescription = "종류") } + // 50 = 48 of touch + the 1dp line above and below; the face is 44 = 48 less 2dp above and below. + assertPx("track", DdsTouch.min + 2 * DdsBorder.hairline, bounds("control").height) + assertPx("segment", DdsTouch.min, segment("개인").height) + assertEquals(50, DdsTouch.min + 2 * DdsBorder.hairline) + assertEquals(44, DdsTouch.min - 2 * DdsSpace.s2) + } + + @Test + fun inLargeTextTheTouchHeightIsTheLargeOne() { + show(largeText = true) { DdsSegmentedControl(kinds, "person", {}, Modifier.testTag("control"), contentDescription = "종류") } + assertPx("track", DdsTouch.large + 2 * DdsBorder.hairline, bounds("control").height) + assertPx("segment", DdsTouch.large, segment("개인").height) + } + + @Test + fun aOneLetterSegmentIsStillAtLeastAFaceWide() { + show { DdsSegmentedControl(listOf(DdsSegment(1, "A"), DdsSegment(2, "B")), 1, {}, contentDescription = "문자") } + assertTrue("A is ${segment("A").width}px", segment("A").width >= px(DdsTouch.min - 2 * DdsSpace.s2) - 1f) + } + + @Test + fun segmentsSitTwoDpInsideTheLineAndTwoDpApart() { + show { DdsSegmentedControl(kinds, "person", {}, Modifier.testTag("control"), contentDescription = "종류") } + val track = bounds("control") + val first = segment("개인") + val second = segment("비즈니스") + assertPx("start inset", DdsBorder.hairline + DdsSpace.s2, first.left - track.left) + assertPx("top inset", DdsBorder.hairline, first.top - track.top) + assertPx("gap", DdsSpace.s2, second.left - first.right) + assertPx("end inset", DdsBorder.hairline + DdsSpace.s2, track.right - second.right) + } + + // --- nothing moves --------------------------------------------------------- + + @Test + fun choosingASegmentMovesNoSegment() { + var chosen by mutableStateOf("day") + val labels = listOf("일", "주", "월간 보기") + show { + DdsSegmentedControl( + listOf(DdsSegment("day", labels[0]), DdsSegment("week", labels[1]), DdsSegment("month", labels[2])), + chosen, + { chosen = it }, + contentDescription = "기간", + ) + } + val before = labels.map(::segment) + rule.onNodeWithText("월간 보기").performClick() + rule.waitForIdle() + assertEquals("month", chosen) + val after = labels.map(::segment) + for (i in labels.indices) { + assertEquals("${labels[i]} left", before[i].left, after[i].left, 0.5f) + assertEquals("${labels[i]} width", before[i].width, after[i].width, 0.5f) + } + } + + @Test + fun fullWidthSegmentsShareTheWidthEquallyAndFillIt() { + show(width = 360) { + DdsSegmentedControl(kinds, "person", {}, Modifier.testTag("control"), contentDescription = "종류", fullWidth = true) + } + val track = bounds("control") + assertPx("track", 360, track.width) + assertEquals(segment("개인").width, segment("비즈니스").width, 1f) + // The last segment ends on the track's inner edge, the same 3dp in as the first one starts. + assertPx("start inset", DdsBorder.hairline + DdsSpace.s2, segment("개인").left - track.left) + assertPx("end inset", DdsBorder.hairline + DdsSpace.s2, track.right - segment("비즈니스").right) + } + + @Test + fun aLongFullWidthLabelEndsInAnEllipsisOnOneLineButIsReadWhole() { + val long = "사업자 등록이 되어 있는 비즈니스 명함" + show(width = 240) { + DdsSegmentedControl(listOf(DdsSegment(1, "개인"), DdsSegment(2, long)), 2, {}, contentDescription = "종류", fullWidth = true) + } + assertEquals("shares stay equal", segment("개인").width, segment(long).width, 1f) + val layout = textLayout(long) + assertEquals(1, layout.lineCount) + assertTrue("the label is cut with an ellipsis", layout.isLineEllipsized(0)) + } + + @Test + fun tooManySegmentsScrollInsideTheTrackAndNoLabelWraps() { + val labels = listOf("전체 보기", "서명 대기 중", "확정된 계약", "취소된 계약", "보관함") + show(width = 200) { + DdsSegmentedControl(labels.map { DdsSegment(it, it) }, labels[0], {}, Modifier.testTag("control"), contentDescription = "보기") + } + assertTrue("the track stays inside its 200dp", bounds("control").width <= px(200) + 1f) + for (label in labels) assertEquals("$label is one line", 1, textLayout(label).lineCount) + rule.onNode(keyIsDefined(SemanticsProperties.HorizontalScrollAxisRange)).assertExists() + } + + // --- pixels ------------------------------------------------------------------ + + private fun control(): PixelMap = rule.onNodeWithTag("control").captureToImage().toPixelMap() + + private fun close(a: Color, b: Color): Boolean = + maxOf(abs(a.red - b.red), abs(a.green - b.green), abs(a.blue - b.blue)) <= 2f / 255f + + private fun assertColor(message: String, expected: Color, actual: Color) = + assertTrue("$message: expected $expected but was $actual", close(expected, actual)) + + /** A segment's box in the control's own pixels. */ + private fun inControl(label: String): Rect = segment(label).translate(-bounds("control").topLeft) + + private fun assertDrawnWith(line: Color) { + val pixels = control() + val chosen = inControl("개인") + val other = inControl("비즈니스") + val half = (oneDp / 2).toInt() + val midY = chosen.center.y.toInt() + assertColor("the track's line", line, pixels[pixels.width / 2, half]) + // Read over the unchosen segment, clear of the chosen face's shadow. + assertColor("the track between its line and the faces", colors.bgSubtle, pixels[other.center.x.toInt(), (2 * oneDp).toInt()]) + assertColor("an unchosen segment shows the track", colors.bgSubtle, pixels[(other.right - 6 * oneDp).toInt(), midY]) + assertColor("the chosen face", colors.bgDefault, pixels[(chosen.left + 6 * oneDp).toInt(), midY]) + assertColor("the chosen face's line", colors.borderStrong, pixels[(chosen.left + half).toInt(), midY]) + } + + @Test + fun theTrackAndTheChosenFaceAreMadeOfTheirTokens() { + show { DdsSegmentedControl(kinds, "person", {}, Modifier.testTag("control"), contentDescription = "종류", fullWidth = true) } + assertDrawnWith(line = colors.borderStrong) + } + + @Test + fun theDarkThemeIsTheSameTokensDark() { + show(dark = true) { DdsSegmentedControl(kinds, "person", {}, Modifier.testTag("control"), contentDescription = "종류", fullWidth = true) } + assertDrawnWith(line = colors.borderStrong) + } + + @Test + fun anErrorTurnsTheTrackLineTheDangerColorAndSaysWhy() { + show { + DdsSegmentedControl(kinds, "person", {}, Modifier.testTag("control"), contentDescription = "종류", fullWidth = true, error = "종류를 고르세요") + } + assertDrawnWith(line = colors.statusDanger) + rule.onNodeWithText("종류를 고르세요").assertExists() + rule.onNode(keyIsDefined(SemanticsProperties.Error)).assert(expectValue(SemanticsProperties.Error, "종류를 고르세요")) + } + + @Test + fun aDisabledControlIsDimmedAsAWhole() { + show { DdsSegmentedControl(kinds, "person", {}, Modifier.testTag("control"), contentDescription = "종류", fullWidth = true, enabled = false) } + val pixels = control() + val other = inControl("비즈니스") + assertColor( + "an unchosen segment: the track at 45%, the page through the rest", + Color( + colors.bgSubtle.red * 0.45f + green.red * 0.55f, + colors.bgSubtle.green * 0.45f + green.green * 0.55f, + colors.bgSubtle.blue * 0.45f + green.blue * 0.55f, + ), + pixels[(other.right - 6 * oneDp).toInt(), other.center.y.toInt()], + ) + } + + // --- screen reader ----------------------------------------------------------- + + @Test + fun eachSegmentIsARadioButtonThatSaysWhetherItIsChosen() { + show { DdsSegmentedControl(kinds, "shop", {}, contentDescription = "명함 종류") } + rule.onNodeWithText("개인") + .assert(expectValue(SemanticsProperties.Role, Role.RadioButton)) + .assert(hasClickAction()) + .assertIsNotSelected() + .assertIsEnabled() + rule.onNodeWithText("비즈니스") + .assert(expectValue(SemanticsProperties.Role, Role.RadioButton)) + .assertIsSelected() + // The unseen bold copy that holds the width is not read: each segment says its words once. + rule.onNodeWithText("개인").assert(expectValue(SemanticsProperties.Text, listOf(androidx.compose.ui.text.AnnotatedString("개인")))) + } + + @Test + fun theSegmentsAreOneGroupWithAName() { + show { DdsSegmentedControl(kinds, "shop", {}, contentDescription = "명함 종류") } + rule.onNode(hasContentDescription("명함 종류")) + .assert(keyIsDefined(SemanticsProperties.SelectableGroup)) + } + + @Test + fun aVisibleLabelNamesTheGroupAndIsNotSaidTwice() { + show { DdsSegmentedControl(kinds, "shop", {}, label = "명함 종류", contentDescription = "다른 이름") } + rule.onNodeWithText("명함 종류").assertExists() + rule.onNode(hasContentDescription("다른 이름")).assertDoesNotExist() + } + + @Test + fun choosingCallsBackWithTheValueButTheChosenSegmentDoesNot() { + val calls = mutableListOf() + show { DdsSegmentedControl(kinds, "person", { calls += it }, contentDescription = "종류") } + rule.onNodeWithText("개인").performClick() + rule.onNodeWithText("비즈니스").performClick() + assertEquals(listOf("shop"), calls) + } + + @Test + fun aReadOnlyControlIsReadButCannotBeChanged() { + val calls = mutableListOf() + show { DdsSegmentedControl(kinds, "shop", { calls += it }, contentDescription = "종류", readOnly = true) } + rule.onNodeWithText("개인") + .assert(expectValue(SemanticsProperties.Role, Role.RadioButton)) + .assert(keyNotDefined(SemanticsActions.OnClick)) + .assertIsNotSelected() + rule.onNodeWithText("비즈니스").assertIsSelected().assert(keyIsDefined(SemanticsProperties.Focused)) + assertTrue(calls.isEmpty()) + } + + @Test + fun aDisabledControlAndADisabledSegmentSaySo() { + show { DdsSegmentedControl(kinds, "person", {}, contentDescription = "종류", enabled = false) } + rule.onNodeWithText("개인").assertIsNotEnabled() + rule.onNodeWithText("비즈니스").assertIsNotEnabled() + } + + @Test + fun aSegmentCanBeDisabledOnItsOwn() { + val calls = mutableListOf() + show { DdsSegmentedControl(listOf(DdsSegment(1, "일"), DdsSegment(2, "연", enabled = false)), 1, { calls += it }, contentDescription = "기간") } + rule.onNodeWithText("일").assertIsEnabled() + rule.onNodeWithText("연").assertIsNotEnabled().performClick() + assertTrue(calls.isEmpty()) + } + + @Test + fun aValueThatIsNoSegmentChoosesNone() { + show { DdsSegmentedControl(kinds, "nothing", {}, contentDescription = "종류") } + rule.onNodeWithText("개인").assertIsNotSelected() + rule.onNodeWithText("비즈니스").assertIsNotSelected() + } +} diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Segmented.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Segmented.kt new file mode 100644 index 00000000..7b5a508a --- /dev/null +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Segmented.kt @@ -0,0 +1,308 @@ +package kr.devslab.dds.compose.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import kr.devslab.dds.compose.motion.clear +import kr.devslab.dds.compose.motion.colorChange +import kr.devslab.dds.compose.theme.DdsTheme +import kr.devslab.dds.tokens.DdsBorder +import kr.devslab.dds.tokens.DdsElevation +import kr.devslab.dds.tokens.DdsRadius +import kr.devslab.dds.tokens.DdsSpace +import kr.devslab.dds.tokens.DdsTouch + +/** + * One choice of a [DdsSegmentedControl]. + * + * @param value what [DdsSegmentedControl]'s `onSelect` receives. Unique within the control + * @param label the words on the segment. One line; it is never wrapped + * @param enabled false greys out this segment alone + */ +public class DdsSegment( + public val value: T, + public val label: String, + public val enabled: Boolean = true, +) + +/** + * One choice among a few visible options: a view switch, a filter, a type + * (Person / Business). The web's SegmentedControl + * (`packages/dds-css/src/segmented-control.css`), at its touch size. + * + * - **Track:** `bg.subtle`, a 1dp `border.strong` line, `radius.md`, 2dp inside + * it and 2dp between segments. + * - **Segment:** `radius.sm`, the `label` type, `text.secondary`. The chosen + * one is a `bg.default` face with a 1dp `border.strong` line, a level-1 + * shadow, `text.primary` and weight 600. + * - **Height:** one size, the web's touch height. The track is 50dp (48dp + * inside its line); each segment is 48dp of touch with a 44dp face and is + * at least 44dp wide. In large text mode the touch height is 56. The web's + * md 40 / sm 32 are pointer heights; on a touch screen both are this one. + * - **Width:** by default each segment is as wide as its label and the track + * as wide as its segments. A label is drawn at the chosen weight's width in + * every state, so choosing a segment never moves its neighbours. Too many + * segments for the width scroll inside the track. [fullWidth] makes the + * track fill the width and share it equally; a label too long for its share + * ends in an ellipsis (a screen reader still reads it whole). + * + * A screen reader hears a radio group: each segment is a radio button that + * says whether it is chosen. Without a visible [label], pass + * [contentDescription] so the group has a name. + * + * A [selected] that is none of the segments' values chooses none of them. + * + * @param segments at least one; their values must be unique + * @param selected the value of the chosen segment + * @param onSelect called with a segment's value when the person chooses it. + * Not called for the segment already chosen, nor while [readOnly] + * @param label the name of the choice, shown above the track + * @param contentDescription the group's name for a screen reader when there is no [label] + * @param error what is wrong with the choice. The track's line turns the + * danger color and the message is shown under it and spoken; never the + * color alone + * @param enabled false greys out the whole control and ignores touches + * @param readOnly shows the choice, can be focused and read, but cannot be + * changed. Not greyed out + */ +@Composable +public fun DdsSegmentedControl( + segments: List>, + selected: T, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + contentDescription: String? = null, + fullWidth: Boolean = false, + error: String? = null, + enabled: Boolean = true, + readOnly: Boolean = false, +) { + checkSegments(segments) // a handful of segments: cheap enough on every composition + val colors = DdsTheme.colors + val typography = DdsTheme.typography + val touch = if (DdsTheme.largeText) DdsTouch.large else DdsTouch.min + val trackShape = RoundedCornerShape(DdsRadius.md.dp) + val trackLine by colorChange(if (error != null) colors.statusDanger else colors.borderStrong) + val scroll = rememberScrollState() + + Column( + modifier.then(if (fullWidth) Modifier.fillMaxWidth() else Modifier), + verticalArrangement = Arrangement.spacedBy(DdsSpace.s4.dp), + ) { + if (label != null) WordsText(label, style = typography.label, color = colors.textPrimary) + SegmentRow( + fullWidth = fullWidth, + touch = touch, + modifier = Modifier + .then(if (fullWidth) Modifier.fillMaxWidth() else Modifier) + // Disabled dims the track as a whole (the web's `[data-disabled]` track). + .alpha(if (enabled) 1f else DISABLED_ALPHA) + .clip(trackShape) + .background(colors.bgSubtle) + .border(DdsBorder.hairline.dp, trackLine, trackShape) + .padding(DdsBorder.hairline.dp) + .then(if (fullWidth) Modifier else Modifier.horizontalScroll(scroll)) + .padding(horizontal = DdsSpace.s2.dp) + .selectableGroup() + .semantics { + if (label == null && contentDescription != null) this.contentDescription = contentDescription + if (error != null) error(error) + }, + ) { + for (segment in segments) { + val chosen = segment.value == selected + Segment( + label = segment.label, + selected = chosen, + // A segment of a disabled control is disabled, but only a segment disabled + // on its own is dimmed: the track is dimmed already. + enabled = enabled && segment.enabled, + dimmed = enabled && !segment.enabled, + readOnly = readOnly, + fullWidth = fullWidth, + touch = touch, + onClick = { if (!chosen) onSelect(segment.value) }, + ) + } + } + if (error != null) FieldMessage(helper = null, error = error) + } +} + +/** + * The segments side by side with 2dp between them, all as tall as the tallest + * and at least [touch] tall. With [fullWidth] the width is shared equally; + * otherwise each is as wide as it wants (it sits inside a horizontal scroll). + */ +@Composable +private fun SegmentRow(fullWidth: Boolean, touch: Int, modifier: Modifier, content: @Composable () -> Unit) { + Layout(content, modifier) { measurables, constraints -> + val gap = DdsSpace.s2.dp.roundToPx() + val gaps = gap * (measurables.size - 1).coerceAtLeast(0) + val count = measurables.size.coerceAtLeast(1) + val room = (constraints.maxWidth - gaps).coerceAtLeast(0) + val share = if (fullWidth && constraints.hasBoundedWidth) room / count else null + // The pixels an equal split leaves over go one each to the first segments, so the + // last one ends on the track's inner edge instead of a pixel or two short of it. + val leftOver = if (share != null) room - share * count else 0 + val height = maxOf( + touch.dp.roundToPx(), + constraints.minHeight, + measurables.maxOfOrNull { it.maxIntrinsicHeight(share ?: Constraints.Infinity) } ?: 0, + ) + val cells = measurables.mapIndexed { index, measurable -> + measurable.measure( + if (share != null) { + Constraints.fixed(share + if (index < leftOver) 1 else 0, height) + } else { + Constraints(minHeight = height, maxHeight = height) + }, + ) + } + val width = cells.sumOf { it.width } + gaps + layout(maxOf(width, constraints.minWidth), height) { + var x = 0 + for (cell in cells) { + cell.placeRelative(x, 0) + x += cell.width + gap + } + } + } +} + +@Composable +private fun Segment( + label: String, + selected: Boolean, + enabled: Boolean, + dimmed: Boolean, + readOnly: Boolean, + fullWidth: Boolean, + touch: Int, + onClick: () -> Unit, +) { + val colors = DdsTheme.colors + val shape = RoundedCornerShape(DdsRadius.sm.dp) + // The face is the touch height less the 2dp above and below it: 44dp (52 in large text). + // A content-width face is at least that wide too, so even "A" is a square to aim at. + val faceMin = (touch - 2 * DdsSpace.s2).dp + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val hovered by interaction.collectIsHoveredAsState() + val focused by interaction.collectIsFocusedAsState() + val active = enabled && !readOnly && !selected + val face by colorChange( + when { + selected -> colors.bgDefault + active && pressed -> colors.overlayPressedTint + active && hovered -> colors.overlayHoverTint + else -> colors.overlayPressedTint.clear() + }, + ) + val ink by colorChange(if (selected || (active && (pressed || hovered))) colors.textPrimary else colors.textSecondary) + // One edge at a time: the focus ring replaces the chosen face's line instead of doubling it. + val edge = when { + focused -> Modifier.border(DdsBorder.focus.dp, colors.borderFocus, shape) + selected -> Modifier.border(DdsBorder.hairline.dp, colors.borderStrong, shape) + else -> Modifier + } + // The touch area is the whole height inside the track's line; the face sits 2dp in from it. + val touchArea = if (readOnly) { + Modifier + .semantics(mergeDescendants = true) { + role = Role.RadioButton + this.selected = selected + } + .focusable(enabled, interaction) + } else { + Modifier.selectable( + selected = selected, + interactionSource = interaction, + indication = null, // the face's own tint is the press feedback, as on the web + enabled = enabled, + role = Role.RadioButton, + onClick = onClick, + ) + } + Box( + touchArea + .alpha(if (dimmed) DISABLED_ALPHA else 1f) + .padding(vertical = DdsSpace.s2.dp), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .fillMaxSize() + .widthIn(min = faceMin) + .then(if (selected) Modifier.shadow(DdsElevation.level1.dp, shape) else Modifier) + .clip(shape) + .background(face) + .then(edge) + .padding(horizontal = DdsSpace.s12.dp), + contentAlignment = Alignment.Center, + ) { + val style = DdsTheme.typography.label + if (fullWidth) { + Text( + label, + Modifier.fillMaxWidth(), + style = style, + color = ink, + fontWeight = if (selected) FontWeight.SemiBold else null, + textAlign = TextAlign.Center, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + } else { + WeightStableLabel(label, heavy = selected, style = style, color = ink) + } + } + } +} + +internal fun checkSegments(segments: List>) { + require(segments.isNotEmpty()) { "DdsSegmentedControl: segments must not be empty" } + val seen = HashSet() + for (segment in segments) { + require(seen.add(segment.value)) { "DdsSegmentedControl: segment values must be unique (${segment.value})" } + } +} diff --git a/android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/component/SegmentedTest.kt b/android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/component/SegmentedTest.kt new file mode 100644 index 00000000..1e26dd43 --- /dev/null +++ b/android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/component/SegmentedTest.kt @@ -0,0 +1,30 @@ +package kr.devslab.dds.compose.component + +import org.junit.Assert.assertThrows +import org.junit.Test + +class SegmentedTest { + @Test + fun distinctValuesAreAccepted() { + checkSegments(listOf(DdsSegment("person", "개인"), DdsSegment("shop", "비즈니스"))) + checkSegments(listOf(DdsSegment(null, "없음"), DdsSegment(1, "하나"))) // null is a value like any other + } + + @Test + fun noSegmentsIsAMistake() { + assertThrows(IllegalArgumentException::class.java) { checkSegments(emptyList()) } + } + + @Test + fun twoSegmentsWithOneValueIsAMistake() { + // Which one would be chosen, and which would onSelect stand for? + assertThrows(IllegalArgumentException::class.java) { + checkSegments(listOf(DdsSegment(1, "일"), DdsSegment(1, "하루"))) + } + } + + @Test + fun theSameLabelOnTwoValuesIsAllowed() { + checkSegments(listOf(DdsSegment(1, "A"), DdsSegment(2, "A"))) + } +} diff --git a/android/gradle.properties b/android/gradle.properties index 9e85037c..1482046f 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -8,4 +8,4 @@ kotlin.code.style=official # Android releases are versioned apart from the web packages (docs/decisions.md D-048). # The release workflow overrides VERSION_NAME from the android-v* tag. GROUP=kr.devslab -VERSION_NAME=0.7.0 +VERSION_NAME=0.8.0 diff --git a/docs/backlog.md b/docs/backlog.md index 72f89be5..20db2de7 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -334,6 +334,9 @@ D-048. 가이드 [`mobile-native.ko.md`](mobile-native.ko.md), 설계 페이지에서 레일·시트·다이얼로그의 픽셀, 제품 바탕 위의 컨트롤 테두리 3:1. - [ ] **입력칸 요청(정산나우)** — 글자의 사본을 남기지 않는 입력칸, 초점이 가면 화면 안으로 끌어오기 (bring-into-view), 최소 줄 수(`minLines`). 그리고 단계 화면의 틀. +- [x] **세그먼트 컨트롤(0.8.0, #149, 2026-11-13)** — `DdsSegmentedControl`·`DdsSegment`. Qooroo의 명함 종류 토글이 + 웹 SegmentedControl 모양을 골라서(소유자) 그 웹 규칙을 터치 크기 그대로 옮겼다. 가이드 §12, 변경 기록 0.8.0. + 소유자가 정할 것: 고른 면의 그림자(§10의 20번). 다음: Qooroo가 앱의 사본 `DdsStyleSegmented`를 이것으로 바꾼다. - [x] **화면 틀(0.7.0) — 배포했다(#147, `android-v0.7.0`, 2026-11-13).** 정산나우가 로컬 빌드로 옮겨 배포 전에 확인했다. 앱의 모든 화면을 DDS 부품으로 옮기고, 475×751·704×933·933×704에서 밝은·어두운 화면으로(475×751은 글자 200%도) `DdsFrameCheck`와 `assertSameLines`로 쟀다 — 1dp 넘게 어긋난 시작선이 없었다. 펼친 폰의 전체 연결 테스트에서 DDS 탓의 실패는 없었다. 거기서도 확인하지 못한 것: 폰을 실제로 가로로 돌린 것, TalkBack, 접힌 폰의 전체 테스트. `DdsAppFrame`, 새 `DdsScaffold`, `DdsBodyColumn`·`ddsBody`·`ddsBodyLines`, 본문 선에 서는 `DdsTopBar` (`onBack`, 두 줄 제목), `DdsCenteredColumn`·`DdsCenteredTitle`, `DdsPaneList`·`DdsPaneItem`, 새 꾸러미 diff --git a/docs/mobile-native.ko.md b/docs/mobile-native.ko.md index 32dc280e..e3f51566 100644 --- a/docs/mobile-native.ko.md +++ b/docs/mobile-native.ko.md @@ -761,6 +761,7 @@ Compose 벡터는 `dds-icons`의 SVG에서 **생성**한다(`node scripts/genera | 17 | 뒤로 가기와 앱 종료 | 이전 화면 → 직전 탭 순으로 돌아가고, 종료는 묻는다. 600ms 안에 두 번 누르면 묻는다 | §2.3.1. 정산나우의 동작 | | 18 | 탭 전환의 움직임 | 새 탭이 이전 탭 위로 나타남, 150ms. 밀기 없음(0.5.0, D-051). `DdsScreenMotion`만 쓰면 없음 | §2.3. 비운 자리 위에서는 같은 페이드가 실물 기기에서 깜빡임으로 읽혔다(0.3.0) | | 19 | 앞으로·뒤로 이동에서 화면을 흐리게 바꾸는가 | 내용: 아니다, 첫 프레임부터 불투명하고 밀기만. 화면: 그렇다, 이전 화면(또는 돌아갈 화면) 위에서 100ms(0.5.0, D-051) | §2.3의 18·20번. 빈 자리 위로 나타내면 실물 기기에서 빈 내용 자리가 보였다(0.3.0) | +| 20 | 고른 세그먼트 면의 그림자 | 웹대로 1단계(`DdsElevation.level1`) | 5번(카드 그림자 없음)은 카드·메뉴의 규칙이다. 세그먼트는 웹 그대로 두었고, 어두운 화면에서는 1dp 선이 구분한다. 0.8.0의 `DdsSegmentedControl` | **항목별 상태 (2026-11-10).** @@ -772,6 +773,7 @@ Compose 벡터는 `dds-icons`의 SVG에서 **생성**한다(`node scripts/genera 소유자가 확정하기 전까지는 확정된 규칙이 아니다. - **아직 소유자에게 묻지 않은 것:** 14, 15, 16번(0.2.0 부품과 함께 추가), 17번(0.3.0, 소유자가 정산나우를 폰에서 쓰며 한 말에서 나왔다 — 숫자는 제안값이다). + 20번(0.8.0의 세그먼트 컨트롤 — 웹 그대로 둔 값이다). - **소유자 말에 따라 바꾼 것(2026-11-11, D-051):** 18·19번. 0.3.0과 0.4.0은 이 페이드를 뺐었다. 소유자는 움직임이 계속 사라지는 것을 원하지 않았고, 비는 프레임이 없는 방식으로 되살렸다. 시간은 0.2.0의 값이다. @@ -876,6 +878,39 @@ Compose 벡터는 `dds-icons`의 SVG에서 **생성**한다(`node scripts/genera `android/catalog`의 Colors 탭 "Product colors"가 이것들을 한 화면에 보여 준다(주황 면 + 흰 글자, 종이색 페이지 위의 흰 카드, 버튼 넷, 끝 맞춤 메뉴). +**0.8.0에서 추가** + +| 부품 | 고정해 주는 것 | 웹 | +| --- | --- | --- | +| `DdsSegmentedControl`, `DdsSegment` | 몇 개 안 되는 보기 중 하나 고르기(보기 전환, 필터, 개인 / 비즈니스 같은 종류). 트랙은 `bg.subtle` + 1dp `border.strong` 선 + 둥글기 8, 안쪽 2dp, 칸 사이 2dp. 칸은 둥글기 4, `label` 글자, `text.secondary`. 고른 칸은 `bg.default` 면 + 1dp `border.strong` 선 + 1단계 그림자 + `text.primary` + 굵기 600. 높이는 하나(터치 높이): 트랙 50 = 터치 48 + 위아래 선 1, 칸의 면 44, 큰 글자 모드는 터치 56. 라벨은 줄바꿈하지 않는다 | SegmentedControl | + +- **고르는 것이 아무것도 밀지 않는다.** 기본(내용 폭)에서는 칸마다 라벨이 굵은 글자의 폭을 + 늘 차지하고 있어서, 다른 칸을 골라도 이웃 칸이 움직이지 않는다. 칸이 너무 많으면 트랙 + 안에서 옆으로 넘긴다. 페이지가 옆으로 밀리지 않는다. +- **`fullWidth = true`면 트랙이 폭을 채우고 칸이 똑같이 나눈다.** 몫보다 긴 라벨은 말줄임표로 + 끝나고, 화면 낭독기는 전부 읽는다. 굵은 폭을 따로 잡아 두지 않는 것은 웹과 같다. +- **웹의 md 40 / sm 32는 포인터 높이다.** 웹도 터치 화면(`pointer: coarse`)에서는 둘 다 트랙 + 50, 칸 44 이상이 된다. 안드로이드는 터치 화면이라 크기 인자가 없다. 칸의 터치 영역은 트랙 + 선 안쪽 높이 전부(48)이고, 면은 그 위아래 2dp 안쪽(44)이다. 내용 폭의 칸은 폭도 44 이상이다. +- **화면 낭독기는 라디오 그룹으로 듣는다.** 칸마다 라디오 버튼이고 골랐는지 말한다. 굵은 폭을 + 잡는 보이지 않는 글은 읽지 않는다. 보이는 `label`이 없으면 `contentDescription`으로 그룹에 + 이름을 준다. +- **`error`는 색만으로 알리지 않는다.** 트랙 선이 위험색이 되고, 트랙 아래에 문장이 나오고, + 낭독기가 읽는다(`DdsTextField`와 같은 줄). `readOnly`는 흐리지 않고, 포커스와 읽기는 되지만 + 바꿀 수 없다. `enabled = false`는 트랙 전체를 0.45로, 칸 하나만 끈 것(`DdsSegment(enabled = + false)`)은 그 칸만 0.45로 그린다. 누름은 물결이 아니라 면의 옅은 덮개, 마우스를 올리면 + 웹처럼 hover 덮개다. 포커스(키보드·D패드)는 칸 안쪽 2dp `border.focus` 선이 고른 칸의 선을 + 대신한다. +- **고른 칸의 그림자는 웹대로 둔다.** 카드·메뉴의 그림자는 뺐지만(§10의 5번) 이 면은 웹 그대로 + 1단계 그림자를 쓴다. 어두운 화면에서는 그림자가 거의 보이지 않아도 1dp 선이 면을 구분한다. + 소유자가 확정할 항목이다(§10의 20번). +- `segments`는 하나 이상이어야 하고 값이 겹치면 안 된다(어기면 예외). `selected`가 어느 칸의 + 값도 아니면 아무 칸도 고르지 않은 상태다. `onSelect`는 이미 고른 칸을 누르거나 `readOnly`일 + 때 부르지 않는다. +- 첫 소비자는 Qooroo의 명함 종류 토글(개인 / 비즈니스)이다. 앱 안의 사본 + `DdsStyleSegmented`를 이것으로 바꾼다. `android/catalog`의 Parts 탭 + "Segmented control"이 전체 폭, 내용 폭(꺼진 칸 포함), 읽기 전용, 비활성, 오류를 보여 준다. + 아직 없는 것: 날짜·시간 선택. `DdsTheme` 아래에서 Material3 부품을 쓴다. ## 13. 화면 틀 (0.7.0, D-052) diff --git a/docs/mobile-native.md b/docs/mobile-native.md index f7971df7..6035031d 100644 --- a/docs/mobile-native.md +++ b/docs/mobile-native.md @@ -795,6 +795,7 @@ in for now"; making them DDS rules is the owner's call. Once decided, | 17 | Back and leaving the app | back to the screen, then the previous tab; ask before leaving; two presses within 600ms ask | §2.3.1. JeongsanNow's behavior | | 18 | Motion on a tab switch | the new tab appears over the one before, 150ms; nothing slides (0.5.0, D-051). Nothing with `DdsScreenMotion` alone | §2.3. Over an emptied page the same fade read as a flicker on a device (0.3.0) | | 19 | Whether a screen fades on push and pop | the content: no, opaque from the first frame, it only slides. The screen: yes, 100ms over the screen before, or the one returned to (0.5.0, D-051) | §2.3, rules 18 and 20. Fading in over nothing showed an empty content area on a device (0.3.0) | +| 20 | The shadow under the chosen segment's face | as on the web, level 1 (`DdsElevation.level1`) | No. 5 (no card shadow) is the rule for cards and menus. The segment is left as the web draws it; in the dark theme the 1dp edge sets it apart. 0.8.0's `DdsSegmentedControl` | **Where each item stands (2026-11-10).** @@ -806,6 +807,7 @@ in for now"; making them DDS rules is the owner's call. Once decided, dialog". These are not confirmed rules until the owner says so. - **Not yet put to the owner:** 14, 15, 16 (added with the 0.2.0 components); 17 (0.3.0, from the owner's own words while using JeongsanNow on a phone — the numbers are proposals). + 20 (0.8.0's segmented control — the value is the web's, left as it is). - **Changed on the owner's word (2026-11-11, D-051):** 18 and 19. 0.3.0 and 0.4.0 had taken these fades out; the owner did not want movement to keep disappearing, so they are back, done the way that leaves no empty frame. The durations are the 0.2.0 ones. @@ -917,6 +919,44 @@ The "Product colors" section on the Colors tab of `android/catalog` shows these screen (white on an orange face, a white card on a paper page, the four buttons, an end-aligned menu). +**Added in 0.8.0** + +| Component | What it settles | Web | +| --- | --- | --- | +| `DdsSegmentedControl`, `DdsSegment` | One choice among a few visible options (a view switch, a filter, a type such as Person / Business). The track is `bg.subtle` with a 1dp `border.strong` edge and radius 8, 2dp inside it and 2dp between segments. A segment has radius 4, the `label` type and `text.secondary`. The chosen one is a `bg.default` face with a 1dp `border.strong` edge, a level-1 shadow, `text.primary` and weight 600. One height, the touch height: the track is 50 = 48 of touch + the 1dp edge above and below, a segment's face is 44, and in large text mode the touch height is 56. Labels never wrap | SegmentedControl | + +- **Choosing moves nothing.** By default (content width) every label takes the width of its + bold form in every state, so choosing another segment never moves its neighbours. Too many + segments scroll sideways inside the track; the page is never pushed sideways. +- **With `fullWidth = true` the track fills the width and the segments share it equally.** + A label longer than its share ends in an ellipsis and a screen reader still reads it whole. + No bold width is reserved, as on the web. +- **The web's md 40 / sm 32 are pointer heights.** On a touch screen (`pointer: coarse`) the + web too makes both a 50 track with segments of at least 44. Android is a touch screen, so + there is no size argument. A segment's touch area is the whole height inside the track's + edge (48); its face sits 2dp in from that above and below (44). A content-width segment is + at least 44 wide too. +- **A screen reader hears a radio group.** Each segment is a radio button that says whether it + is chosen. The unseen text that holds the bold width is not read. Without a visible `label`, + give the group a name with `contentDescription`. +- **`error` is never the color alone.** The track's edge turns the danger color, a sentence + appears under the track and is spoken (the same line as `DdsTextField`'s). `readOnly` is not + greyed out: it can be focused and read but not changed. `enabled = false` draws the whole + track at 0.45; a segment turned off on its own (`DdsSegment(enabled = false)`) is drawn at + 0.45 alone. A press is a pale tint on the face, not a ripple; a mouse over it shows the hover + tint as on the web. Focus (keyboard, D-pad) is a 2dp `border.focus` line inside the segment + that takes the place of the chosen face's edge. +- **The chosen face keeps the web's shadow.** Cards and menus dropped theirs (§10 no. 5), but + this face uses the web's level-1 shadow. In the dark theme, where a shadow hardly shows, the + 1dp edge sets the face apart. It is for the owner to settle (§10 no. 20). +- `segments` must not be empty and their values must not repeat (either throws). A `selected` + that is none of the values chooses none. `onSelect` is not called for the segment already + chosen, nor while `readOnly`. +- The first consumer is Qooroo's card-type toggle (Person / Business), whose copy in the app, + `DdsStyleSegmented`, this replaces. The "Segmented control" + section on the Parts tab of `android/catalog` shows full width, content width (with a + segment turned off), read-only, disabled and an error. + Not yet built: date and time pickers. Use the Material3 ones under `DdsTheme`. ## 13. Screen frame (0.7.0, D-052)