Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/segmented-slide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@devslab/dds-css": minor
"@devslab/dds-react": minor
"@devslab/dds-solid": minor
"@devslab/dds-vue": minor
---

SegmentedControl's chosen face slides to the new choice (D-055): one `.dds-segmented__indicator` in the track, placed on the chosen item and moved over `motion.duration.base` with the standard easing when the choice changes (not on a first placement or a resize; 0ms under `prefers-reduced-motion`). React, Solid and Vue measure it with the same small function. Where there is no `ResizeObserver` — a server render, a jsdom test, an old browser — it does nothing: the indicator stays hidden and the chosen item paints its own face, as before, so consumers' tests need no polyfill.
2 changes: 1 addition & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). 0.8.0(2026-11-13)에서 `DdsSegmentedControl`이 생겼고, 0.8.1에서 어두운 화면의 고른 칸이 밝아졌다(`bg.raised`, D-054). 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`이 생겼고, 0.8.1에서 어두운 화면의 고른 칸이 밝아졌고(`bg.raised`, D-054), 0.9.0에서 고른 면이 미끄러져 옮겨 간다(D-055). RN·Ionic 패키지는 그 제품이 생길 때.

## 입력·선택·공유 달력 확장

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). 0.8.0 (2026-11-13) adds `DdsSegmentedControl`, and 0.8.1 its lighter chosen face in dark (`bg.raised`, D-054). 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`, 0.8.1 its lighter chosen face in dark (`bg.raised`, D-054), and 0.9.0 makes that face slide between choices (D-055). RN and Ionic packages only when such a product exists.

## Input, choice and shared calendar additions

Expand Down
9 changes: 9 additions & 0 deletions android/CHANGELOG.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
`kr.devslab:dds-android-tokens`, `kr.devslab:dds-compose`, 그리고 0.7.0부터 `kr.devslab:dds-compose-testing`은 한 버전을 쓰고, 웹 패키지와는
버전이 따로다(D-048). 릴리스는 `android-v<버전>` 태그로 낸다.

## 0.9.0 — 2026-11-13

**바뀐 것**

- **세그먼트 컨트롤의 고른 면이 미끄러져 옮겨 간다(D-055).** 0.8.x는 이전 칸의 면이 사라지고 새 칸의 면이 나타났다.
이제 면 하나가 행 뒤에 있고, 다른 칸을 고르면 200ms(`DdsMotion.Base`, `Standard`)에 걸쳐 그 칸으로 가며 폭도 따라
바뀐다. 처음 그릴 때와 회전·글자 크기로 자리만 바뀔 때는 움직이지 않는다. 기기에서 애니메이션을 끄면 바로 옮겨 간다.
API는 그대로다. 웹 SegmentedControl도 같이 바뀌었다.

## 0.8.1 — 2026-11-13

**바뀐 것**
Expand Down
10 changes: 10 additions & 0 deletions android/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@
`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<version>` tag.

## 0.9.0 — 2026-11-13

**Changed**

- **The segmented control's chosen face slides (D-055).** In 0.8.x the old segment's face went and the new one's
appeared. Now one face sits behind the row; choosing another segment moves it there over 200ms (`DdsMotion.Base`,
`Standard`), its width following. The first layout, and a new place for the same choice (rotation, font size), put it
there without travel. With animations off on the device it moves at once. No API change. The web SegmentedControl
changed with it.

## 0.8.1 — 2026-11-13

**Changed**
Expand Down
4 changes: 2 additions & 2 deletions android/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ dependencies {

```kotlin
dependencies {
implementation("kr.devslab:dds-compose:0.8.1")
androidTestImplementation("kr.devslab:dds-compose-testing:0.8.1")
implementation("kr.devslab:dds-compose:0.9.0")
androidTestImplementation("kr.devslab:dds-compose-testing:0.9.0")
}
```

Expand Down
4 changes: 2 additions & 2 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ dependencies {

```kotlin
dependencies {
implementation("kr.devslab:dds-compose:0.8.1")
androidTestImplementation("kr.devslab:dds-compose-testing:0.8.1")
implementation("kr.devslab:dds-compose:0.9.0")
androidTestImplementation("kr.devslab:dds-compose-testing:0.9.0")
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ 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.component.SEGMENT_THUMB_TAG
import kr.devslab.dds.compose.motion.isReducedMotion
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assume.assumeFalse
import kr.devslab.dds.compose.theme.DdsColors
import kr.devslab.dds.compose.theme.DdsTheme
import kr.devslab.dds.tokens.DdsBorder
Expand Down Expand Up @@ -201,6 +205,62 @@ class SegmentedOnDeviceTest {
rule.onNode(keyIsDefined(SemanticsProperties.HorizontalScrollAxisRange)).assertExists()
}

// --- the chosen face slides (D-055) ---------------------------------------------

private fun thumb(): Rect = rule.onNodeWithTag(SEGMENT_THUMB_TAG, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot

@Test
fun onTheFirstFrameTheFaceIsAlreadyOnTheChosenSegment() {
// The clock is held from the start, so this is the first frame, not a settled one.
rule.mainClock.autoAdvance = false
rule.setContent {
DdsTheme { Box(Modifier.width(360.dp)) { DdsSegmentedControl(kinds, "shop", {}, contentDescription = "종류", fullWidth = true) } }
}
rule.mainClock.advanceTimeByFrame()
val face = thumb()
val chosen = segment("비즈니스")
assertEquals("face start", chosen.left, face.left, 1f)
assertEquals("face end", chosen.right, face.right, 1f)
// 2dp in from the touch area above and below: the 44dp face.
assertPx("face height", DdsTouch.min - 2 * DdsSpace.s2, face.height)
rule.mainClock.autoAdvance = true
}

@Test
fun choosingSlidesTheFaceAcrossAndItEndsOnTheNewSegment() {
assumeFalse("animations are turned off on this device", isReducedMotion(InstrumentationRegistry.getInstrumentation().targetContext))
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 from = segment(labels[0])
val to = segment(labels[2])
rule.mainClock.autoAdvance = false
rule.onNodeWithText(labels[2]).performClick()
rule.mainClock.advanceTimeBy(100) // half of the 200ms move
val midway = thumb()
assertTrue("midway the face is past where it was: $midway", midway.left > from.left + 1f)
assertTrue("and short of where it goes: $midway", midway.left < to.left - 1f)
assertTrue("its width is between the two: $midway", midway.width > from.width + 1f && midway.width < to.width - 1f)
rule.mainClock.autoAdvance = true
rule.waitForIdle()
val end = thumb()
assertEquals("end start", to.left, end.left, 1f)
assertEquals("end width", to.width, end.width, 1f)
}

@Test
fun noChoiceDrawsNoFace() {
show { DdsSegmentedControl(kinds, "nothing", {}, contentDescription = "종류") }
rule.onNodeWithTag(SEGMENT_THUMB_TAG, useUnmergedTree = true).assertDoesNotExist()
}

// --- pixels ------------------------------------------------------------------

private fun control(): PixelMap = rule.onNodeWithTag("control").captureToImage().toPixelMap()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ 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.platform.testTag
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
import kr.devslab.dds.compose.motion.DdsMotion
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.error
Expand Down Expand Up @@ -72,6 +82,10 @@ public class DdsSegment<out T>(
* - **Segment:** `radius.sm`, the `label` type, `text.secondary`. The chosen
* one is a `bg.raised` face with a 1dp `border.strong` line, a level-1
* shadow, `text.primary` and weight 600.
* - **Motion:** the chosen face slides to the new choice (200ms, standard
* easing), growing or shrinking to its width; the label colors fade. With
* animations off on the device it moves at once. The first frame already
* shows it on the chosen segment.
* - **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
Expand Down Expand Up @@ -131,6 +145,7 @@ public fun <T> DdsSegmentedControl(
SegmentRow(
fullWidth = fullWidth,
touch = touch,
chosen = segments.indexOfFirst { it.value == selected },
modifier = Modifier
.then(if (fullWidth) Modifier.fillMaxWidth() else Modifier)
// Disabled dims the track as a whole (the web's `[data-disabled]` track).
Expand Down Expand Up @@ -171,10 +186,47 @@ public fun <T> DdsSegmentedControl(
* 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).
*
* The chosen face is one thumb behind the segments, not a face on each (D-055):
* choosing another segment slides it there, growing or shrinking to that
* segment's width on the way, over [DdsMotion.Base] with the standard easing —
* the web's indicator and [DdsTabs]'s underline move the same way. The first
* layout, and a new place for the same choice (rotation, font size), put it
* there without travel. [chosen] is -1 when no segment is chosen: no thumb.
*/
@Composable
private fun SegmentRow(fullWidth: Boolean, touch: Int, modifier: Modifier, content: @Composable () -> Unit) {
Layout(content, modifier) { measurables, constraints ->
private fun SegmentRow(fullWidth: Boolean, touch: Int, chosen: Int, modifier: Modifier, content: @Composable () -> Unit) {
// Where each segment starts and ends, in px from the start edge. Written when the row is
// placed; read when the choice changes, never while the row is measured.
var spans by remember { mutableStateOf<List<IntRange>>(emptyList()) }
val span = spans.getOrNull(chosen)
val thumbStart = remember { Animatable(0f) }
val thumbEnd = remember { Animatable(0f) }
var thumbFor by remember { mutableIntStateOf(-1) } // the segment the thumb last went to; -1 before the first layout
LaunchedEffect(span, chosen) {
span ?: return@LaunchedEffect
val start = span.first.toFloat()
val end = (span.last + 1).toFloat()
if (thumbFor == chosen || thumbFor == -1) {
thumbStart.snapTo(start)
thumbEnd.snapTo(end)
} else {
val slide = tween<Float>(DdsMotion.Base, easing = DdsMotion.Standard)
launch { thumbStart.animateTo(start, slide) }
launch { thumbEnd.animateTo(end, slide) }
}
thumbFor = chosen
}
val hasThumb = chosen >= 0
Layout(
content = {
if (hasThumb) SegmentThumb()
content()
},
modifier = modifier,
) { all, constraints ->
val thumb = if (hasThumb) all.first() else null
val measurables = if (hasThumb) all.drop(1) else all
val gap = DdsSpace.s2.dp.roundToPx()
val gaps = gap * (measurables.size - 1).coerceAtLeast(0)
val count = measurables.size.coerceAtLeast(1)
Expand All @@ -198,16 +250,49 @@ private fun SegmentRow(fullWidth: Boolean, touch: Int, modifier: Modifier, conte
)
}
val width = cells.sumOf { it.width } + gaps
val placed = ArrayList<IntRange>(cells.size)
var edge = 0
for (cell in cells) {
placed += edge until edge + cell.width
edge += cell.width + gap
}
// The face sits 2dp in from the top and bottom of the touch area, as each segment's own did.
val inset = DdsSpace.s2.dp.roundToPx()
// Before the thumb has been sent anywhere it stands on the chosen segment: the first
// frame already has it, with no slide in from the start edge.
val target = placed.getOrNull(chosen)
val travelled = thumbFor != -1
val thumbX = if (travelled) thumbStart.value.roundToInt() else target?.first ?: 0
val thumbWidth = if (travelled) (thumbEnd.value - thumbStart.value).roundToInt() else target?.let { it.last + 1 - it.first } ?: 0
val thumbPlaceable = thumb?.measure(Constraints.fixed(thumbWidth.coerceAtLeast(0), (height - 2 * inset).coerceAtLeast(0)))
layout(maxOf(width, constraints.minWidth), height) {
var x = 0
for (cell in cells) {
cell.placeRelative(x, 0)
x += cell.width + gap
}
thumbPlaceable?.placeRelative(thumbX, inset)
for ((index, cell) in cells.withIndex()) cell.placeRelative(placed[index].first, 0)
// Recorded here, not while measuring: this block runs only for a real layout, while
// the measuring above also answers intrinsic-size questions with stand-in sizes.
spans = placed // an equal list changes nothing, so this settles
}
}
}

/** The chosen face: `bg.raised` with a 1dp `border.strong` line and a level-1 shadow (D-054). */
@Composable
private fun SegmentThumb() {
val colors = DdsTheme.colors
val shape = RoundedCornerShape(DdsRadius.sm.dp)
Box(
Modifier
.testTag(SEGMENT_THUMB_TAG)
.shadow(DdsElevation.level1.dp, shape)
.clip(shape)
.background(colors.bgRaised)
.border(DdsBorder.hairline.dp, colors.borderStrong, shape),
)
}

/** The thumb's test tag, for this module's device tests. */
internal const val SEGMENT_THUMB_TAG: String = "dds-segmented-thumb"

@Composable
private fun Segment(
label: String,
Expand All @@ -231,19 +316,15 @@ private fun Segment(
val active = enabled && !readOnly && !selected
val face by colorChange(
when {
selected -> colors.bgRaised
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 chosen face is the thumb behind the row; a segment draws only press, hover and focus.
// The focus ring lies over the thumb's line on the chosen segment.
val edge = if (focused) Modifier.border(DdsBorder.focus.dp, colors.borderFocus, 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
Expand Down Expand Up @@ -272,7 +353,6 @@ private fun Segment(
Modifier
.fillMaxSize()
.widthIn(min = faceMin)
.then(if (selected) Modifier.shadow(DdsElevation.level1.dp, shape) else Modifier)
.clip(shape)
.background(face)
.then(edge)
Expand Down
2 changes: 1 addition & 1 deletion android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.8.1
VERSION_NAME=0.9.0
2 changes: 1 addition & 1 deletion docs/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ D-048. 가이드 [`mobile-native.ko.md`](mobile-native.ko.md), 설계
(bring-into-view), 최소 줄 수(`minLines`). 그리고 단계 화면의 틀.
- [x] **세그먼트 컨트롤(0.8.0, #149, 2026-11-13)** — `DdsSegmentedControl`·`DdsSegment`. Qooroo의 명함 종류 토글이
웹 SegmentedControl 모양을 골라서(소유자) 그 웹 규칙을 터치 크기 그대로 옮겼다. 가이드 §12, 변경 기록 0.8.0.
고른 면은 0.8.1에서 `bg.raised`(어두운 `zinc.800`), 그림자 1단계로 확정(D-054, §10의 20번). 다음: Qooroo가 앱의 사본 `DdsStyleSegmented`를 이것으로 바꾼다.
고른 면은 0.8.1에서 `bg.raised`(어두운 `zinc.800`), 그림자 1단계로 확정(D-054, §10의 20번). 0.9.0에서 고른 면이 미끄러져 옮겨 간다(D-055, 웹 함께). 다음: 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`, 새 꾸러미
Expand Down
Loading
Loading