diff --git a/.changeset/segmented-slide.md b/.changeset/segmented-slide.md new file mode 100644 index 00000000..9dc1dea5 --- /dev/null +++ b/.changeset/segmented-slide.md @@ -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. diff --git a/README.ko.md b/README.ko.md index 3ca80355..01a50579 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). 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 패키지는 그 제품이 생길 때. ## 입력·선택·공유 달력 확장 diff --git a/README.md b/README.md index b43be1ce..9531b98c 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). 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 diff --git a/android/CHANGELOG.ko.md b/android/CHANGELOG.ko.md index c9340302..af50db5d 100644 --- a/android/CHANGELOG.ko.md +++ b/android/CHANGELOG.ko.md @@ -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 **바뀐 것** diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 0e94a153..8c5099fd 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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` 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** diff --git a/android/README.ko.md b/android/README.ko.md index 57d88fda..c8092a41 100644 --- a/android/README.ko.md +++ b/android/README.ko.md @@ -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") } ``` diff --git a/android/README.md b/android/README.md index 647623df..d5263d6c 100644 --- a/android/README.md +++ b/android/README.md @@ -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") } ``` 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 index 7d12f7b4..505444ea 100644 --- 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 @@ -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 @@ -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() 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 index b355b200..67bc3861 100644 --- 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 @@ -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 @@ -72,6 +82,10 @@ public class DdsSegment( * - **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 @@ -131,6 +145,7 @@ public fun 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). @@ -171,10 +186,47 @@ public fun 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>(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(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) @@ -198,16 +250,49 @@ private fun SegmentRow(fullWidth: Boolean, touch: Int, modifier: Modifier, conte ) } val width = cells.sumOf { it.width } + gaps + val placed = ArrayList(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, @@ -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 @@ -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) diff --git a/android/gradle.properties b/android/gradle.properties index 72871498..7c2d7723 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.8.1 +VERSION_NAME=0.9.0 diff --git a/docs/backlog.md b/docs/backlog.md index d075d08f..1d3244a4 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -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`, 새 꾸러미 diff --git a/docs/decisions.md b/docs/decisions.md index 70943117..11ac73bc 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,6 +5,40 @@ --- +## D-055 — 세그먼트의 고른 면은 미끄러져 옮겨 간다 (2026-11-13) + +**문제.** 세그먼트 컨트롤은 다른 칸을 고르면 이전 칸의 면이 사라지고 새 칸의 면이 나타났다(색만 100ms에 +바뀜). 무엇이 어디로 옮겨 갔는지 보이지 않는다. 소유자 질문 "움직이면서 애니메이션 있는거지?"에 대한 답이 +"색만 바뀐다"였고, 소유자가 미끄러지는 면을 골랐다("Yes, add sliding", 2026-11-13). + +**결정.** + +1. **고른 면은 칸마다 하나씩이 아니라 트랙에 하나다.** 다른 칸을 고르면 그 칸으로 미끄러져 가며, 가는 동안 + 새 칸의 폭으로 늘거나 준다. `motion.duration.base`(200ms), `standard` 곡선 — 안드로이드 `DdsTabs` 밑줄과 같은 + 움직임이다. 면의 모양은 D-054 그대로(`bg.raised`, 1dp `border.strong`, 그림자 1단계). +2. **옮겨 갈 때만 움직인다.** 처음 그릴 때, 같은 선택이 자리만 바뀔 때(회전, 글자 크기, 창 크기)는 움직이지 않고 + 그 자리에 놓인다. 첫 프레임부터 고른 칸 위에 있다. +3. **움직임 줄이기를 따른다.** 웹은 `prefers-reduced-motion`에서 0ms, 안드로이드는 기기의 애니메이션 끄기에서 바로. +4. **웹은 잴 수 있을 때만 미끄러진다.** 트랙 안에 `.dds-segmented__indicator` 하나(`hidden`)를 두고, 브라우저에서 + 붙는 작은 함수 하나(`attachSegmentIndicator`, dds-react·dds-solid·dds-vue에 같은 파일 — 테스트가 셋이 같은지 본다)가 + 고른 칸의 상자(`--left/--top/--width/--height`)를 넣는다. 고른 칸이 바뀔 때만 위치 속성에 트랜지션을 걸고, 처음 놓을 + 때와 크기가 바뀔 때는 걸지 않는다. **`ResizeObserver`가 없으면(서버, jsdom 테스트, 오래된 브라우저) 아무것도 하지 + 않는다** — 표시는 숨은 채로, 고른 칸이 지금처럼 자기 면을 그린다. 미끄러짐만 없고 잃는 것은 없다. 재면 `:has()`로 + 칸의 면을 비우고 표시가 면이 된다. 서버와 클라이언트 첫 렌더가 같은 숨은 `span`이라 하이드레이션도 그대로다. + 처음에는 Ark `SegmentGroup.Indicator`(Zag)를 썼다. Zag는 표시가 있으면 `ResizeObserver`를 조건 없이 쓰고 + (`win.ResizeObserver is not a constructor` — CI의 Solid 하이드레이트 검사가 잡았다), 시작할 때와 값이 바뀔 때만 재서 + 나중에 붙이는 우회도 안 된다. 소비자의 jsdom 테스트에 폴리필을 요구하지 않으려고 직접 쟀다(소유자 지시: 테스트 + 쪽 폴리필로만 덮지 말 것). +5. **안드로이드는 행 뒤의 손잡이(thumb) 하나.** 칸은 누름·hover·포커스만 그린다. 포커스 링은 손잡이의 선 위에 놓인다. + +**대안.** ⓪ Ark `SegmentGroup.Indicator` — 위 4번. ① CSS 앵커 위치 지정(`anchor-name`) — 스크립트가 필요 없지만 Firefox가 아직 지원하지 않고, 앵커가 +바뀔 때의 트랜지션이 브라우저마다 다르며, 한 페이지의 여러 컨트롤에 `anchor-scope`가 필요하다. ② 지금처럼 색만 +— 소유자가 미끄러지는 쪽을 골랐다. ③ 300ms(`slow`) — 탭 밑줄과 다른 속도가 된다. + +**다시 볼 때.** 앵커 위치 지정이 세 브라우저에서 트랜지션까지 같아지면 웹의 스크립트를 걷어 낼 수 있다. + +--- + ## D-054 — 고른 세그먼트는 `bg.raised` 면에 그림자 1단계 (2026-11-13) **문제.** 세그먼트 컨트롤의 고른 칸은 `bg.default` 면이었다. 밝은 화면에서는 흰 면이 `bg.subtle` diff --git a/docs/mobile-native.ko.md b/docs/mobile-native.ko.md index c38b9f69..bcdb32c3 100644 --- a/docs/mobile-native.ko.md +++ b/docs/mobile-native.ko.md @@ -901,6 +901,10 @@ Compose 벡터는 `dds-icons`의 SVG에서 **생성**한다(`node scripts/genera false)`)은 그 칸만 0.45로 그린다. 누름은 물결이 아니라 면의 옅은 덮개, 마우스를 올리면 웹처럼 hover 덮개다. 포커스(키보드·D패드)는 칸 안쪽 2dp `border.focus` 선이 고른 칸의 선을 대신한다. +- **고른 면은 미끄러져 옮겨 간다(0.9.0, D-055).** 칸마다 면을 그리지 않고 행 뒤에 면 하나를 둔다. 다른 칸을 + 고르면 200ms(`DdsMotion.Base`, `Standard` 곡선)에 걸쳐 그 칸으로 가며 폭도 따라 바뀐다 — `DdsTabs` 밑줄과 같다. + 처음 그릴 때와 회전·글자 크기로 자리만 바뀔 때는 움직이지 않는다. 기기에서 애니메이션을 끄면 바로 옮겨 간다. + 웹도 같다. - **고른 칸은 트랙보다 밝은 면이다(0.8.1, D-054).** 카드·메뉴의 그림자는 뺐지만(§10의 5번) 이 면은 웹 그대로 1단계 그림자를 쓴다. 어두운 화면에서는 그림자가 보이지 않으므로 면 자체가 트랙 (`bg.subtle` `zinc.900`)보다 한 단계 밝은 `bg.raised`(`zinc.800`)다. 0.8.0은 `bg.default` diff --git a/docs/mobile-native.md b/docs/mobile-native.md index 22b58635..b513fc98 100644 --- a/docs/mobile-native.md +++ b/docs/mobile-native.md @@ -946,6 +946,11 @@ end-aligned menu). 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 slides (0.9.0, D-055).** One face sits behind the row instead of one on each + segment. Choosing another segment moves it there over 200ms (`DdsMotion.Base`, the `Standard` + easing), its width following — as `DdsTabs`'s underline moves. 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. The web does the same. - **The chosen face is lighter than the track (0.8.1, D-054).** Cards and menus dropped their shadow (§10 no. 5), but this face keeps the web's level-1 shadow. In the dark theme, where the shadow does not show, the face itself is `bg.raised` (`zinc.800`), one step lighter than the diff --git a/packages/dds-css/src/segmented-control.css b/packages/dds-css/src/segmented-control.css index 997600a6..1066945a 100644 --- a/packages/dds-css/src/segmented-control.css +++ b/packages/dds-css/src/segmented-control.css @@ -29,6 +29,8 @@ } .dds-segmented__track { + /* The sliding indicator is placed against the track. */ + position: relative; display: inline-flex; align-items: stretch; align-self: flex-start; @@ -57,6 +59,8 @@ .dds-segmented__item { position: relative; + /* Above the sliding indicator. */ + z-index: 1; display: inline-flex; align-items: center; justify-content: center; @@ -124,6 +128,44 @@ font-weight: 600; } +/* The chosen face slides from the old choice to the new one (D-055). The + * framework packages render one .dds-segmented__indicator in the track and + * set --left/--top/--width/--height to the chosen item's box; it is hidden + * until measured. Until then — server-rendered first paint, plain HTML with + * no script, a page with no ResizeObserver — the chosen item draws its own + * face (above), so nothing is lost. + * The move takes motion.duration.base with the standard easing; only a change + * of choice animates, never a resize. */ +.dds-segmented__indicator { + position: absolute; + /* Physical on purpose: the measured offset is from the track's left edge in + * either writing direction. */ + left: var(--left); + top: var(--top); + width: var(--width); + height: var(--height); + box-sizing: border-box; + border: 1px solid var(--dds-color-border-strong); + border-radius: var(--dds-radius-sm); + background: var(--dds-color-bg-raised); + box-shadow: var(--dds-elevation-1); + pointer-events: none; + /* The script names the properties only when the choice changes; a first + * placement and a resize set none, so they do not travel. */ + transition-property: none; + transition-duration: var(--dds-motion-duration-base); + transition-timing-function: var(--dds-motion-easing-standard); +} +.dds-segmented__indicator[hidden] { + display: none; +} +.dds-segmented__track:has(> .dds-segmented__indicator:not([hidden])) + .dds-segmented__item[data-state="checked"] { + border-color: transparent; + background: transparent; + box-shadow: none; +} + .dds-segmented__item[data-focus-visible] { outline: var(--dds-space-2) solid var(--dds-color-border-focus); outline-offset: calc(var(--dds-space-2) * -1); @@ -153,6 +195,9 @@ .dds-segmented__item { transition: none; } + .dds-segmented__indicator { + transition-duration: 0ms; + } } @media (pointer: coarse) { diff --git a/packages/dds-react/src/__tests__/segmented-control.test.tsx b/packages/dds-react/src/__tests__/segmented-control.test.tsx index 7e31e2eb..cb44fddf 100644 --- a/packages/dds-react/src/__tests__/segmented-control.test.tsx +++ b/packages/dds-react/src/__tests__/segmented-control.test.tsx @@ -20,6 +20,60 @@ const choose = async (input: HTMLInputElement) => { }; describe("SegmentedControl", () => { + // The sliding face (D-055) needs ResizeObserver. Where there is none (a server, a test DOM, an + // old browser) the indicator stays hidden and the chosen item paints its own face. + it("without ResizeObserver it renders, chooses, and leaves the indicator hidden", async () => { + const saved = [window.ResizeObserver, globalThis.ResizeObserver]; + // @ts-expect-error removed on purpose + delete window.ResizeObserver; + // @ts-expect-error removed on purpose + delete globalThis.ResizeObserver; + try { + const { container: host } = render(); + const indicator = host.querySelector(".dds-segmented__track > .dds-segmented__indicator")!; + expect(indicator.hidden).toBe(true); + expect(indicator.getAttribute("aria-hidden")).toBe("true"); + await choose(radio(host, "month")); + expect(segment(host, "month").getAttribute("data-state")).toBe("checked"); + expect(indicator.hidden).toBe(true); + } finally { + window.ResizeObserver = saved[0]!; + globalThis.ResizeObserver = saved[1]!; + } + }); + + it("with ResizeObserver it places the indicator on the chosen item and slides it only on a change", async () => { + class Observer { observe() {} unobserve() {} disconnect() {} } + const saved = window.ResizeObserver; + window.ResizeObserver = Observer as unknown as typeof ResizeObserver; + // jsdom lays nothing out: give every item a box, wider for a later one. + const proto = HTMLElement.prototype; + const descriptors = ["offsetLeft", "offsetTop", "offsetWidth", "offsetHeight"].map((key) => [key, Object.getOwnPropertyDescriptor(proto, key)] as const); + const index = (el: HTMLElement) => Array.from(el.parentElement?.children ?? []).indexOf(el); + Object.defineProperty(proto, "offsetLeft", { configurable: true, get() { return 40 * index(this); } }); + Object.defineProperty(proto, "offsetTop", { configurable: true, get() { return 2; } }); + Object.defineProperty(proto, "offsetWidth", { configurable: true, get() { return 36; } }); + Object.defineProperty(proto, "offsetHeight", { configurable: true, get() { return 34; } }); + try { + const { container: host } = render(); + await tick(); + const indicator = host.querySelector(".dds-segmented__indicator")!; + expect(indicator.hidden).toBe(false); + expect(indicator.style.getPropertyValue("--left")).toBe(`${segment(host, "day").offsetLeft}px`); + expect(indicator.style.getPropertyValue("--width")).toBe("36px"); + expect(indicator.style.transitionProperty).toBe(""); // the first placement does not travel: the CSS says none + await choose(radio(host, "month")); + expect(indicator.style.getPropertyValue("--left")).toBe(`${segment(host, "month").offsetLeft}px`); + expect(indicator.style.transitionProperty).toBe("left, top, width, height"); + } finally { + for (const [key, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(proto, key, descriptor); + else delete (proto as unknown as Record)[key]; + } + window.ResizeObserver = saved; + } + }); + it("is a labelled radio group whose segments are named by their text", () => { const { container: host } = render( , diff --git a/packages/dds-react/src/segmented-control.tsx b/packages/dds-react/src/segmented-control.tsx index 23aedd7a..f6a336a8 100644 --- a/packages/dds-react/src/segmented-control.tsx +++ b/packages/dds-react/src/segmented-control.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useId, useRef } from "react"; import { choiceWarningsEnabled } from "./choice-types"; import { classes } from "./controls"; import { useDirection, type Direction } from "./direction"; +import { attachSegmentIndicator } from "./segmented-indicator"; export interface SegmentedControlItem { /** Nonempty ID, unique within the control. This is what callbacks and forms receive. */ @@ -63,6 +64,10 @@ export function SegmentedControl(props: SegmentedControlProps) { const direction = useDirection(root, props.dir); // Native form reset returns to the first rendered choice. const initialValue = useRef(props.value ?? props.defaultValue).current; + const track = useRef(null); + // The sliding face (D-055): measured in the browser only; until then, and wherever there is no + // ResizeObserver, the chosen item paints its own face. + useEffect(() => (track.current ? attachSegmentIndicator(track.current) : undefined), []); const { items, label } = props; const ariaLabel = props["aria-label"]; const ariaLabelledby = props["aria-labelledby"]; @@ -120,7 +125,9 @@ export function SegmentedControl(props: SegmentedControlProps) { {label} ) : null} -
+
+ {/* The chosen face, sliding between choices (D-055). Hidden until measured. */} +