From 7c445baadcdb622534af694543114385fc2e844f Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Fri, 13 Nov 2026 13:31:53 +0900 Subject: [PATCH 1/2] feat(segmented): the chosen face slides to the new choice (D-055, Android 0.9.0) Choosing another segment used to swap faces: the old one's went, the new one's appeared, only the colors fading. The owner chose a sliding face. One face per control instead of one per segment. It moves to the new choice over motion.duration.base (200ms) with the standard easing, its width following, the same move as DdsTabs's underline. The first layout and a new place for the same choice (rotation, font size, resize) put it there without travel; reduced motion moves it at once. Web: one .dds-segmented__indicator in the track (React and Solid render Ark's SegmentGroup.Indicator; Vue measures it itself). It stays hidden until measured, and until then the chosen item draws its own face, so server- rendered first paint and plain HTML lose nothing. Android: one thumb behind the row; segments draw only press, hover and focus. Tests: browser tests for React, Solid and Vue (face on the chosen item, one face, travels, ends on the new item), a source contract, and device tests (first frame already on the chosen segment, midway between, ends on it). --- .changeset/segmented-slide.md | 8 ++ README.ko.md | 2 +- README.md | 2 +- android/CHANGELOG.ko.md | 9 ++ android/CHANGELOG.md | 10 ++ android/README.ko.md | 4 +- android/README.md | 4 +- .../dds/compose/SegmentedOnDeviceTest.kt | 60 ++++++++++ .../dds/compose/component/Segmented.kt | 110 +++++++++++++++--- android/gradle.properties | 2 +- docs/backlog.md | 2 +- docs/decisions.md | 29 +++++ docs/mobile-native.ko.md | 4 + docs/mobile-native.md | 5 + packages/dds-css/src/segmented-control.css | 41 +++++++ packages/dds-react/src/segmented-control.tsx | 2 + packages/dds-solid/src/segmented-control.tsx | 2 + packages/dds-vue/src/controls.ts | 45 ++++++- tests/browser/components.spec.ts | 4 + tests/browser/segmented-slide.ts | 34 ++++++ tests/browser/vue.spec.ts | 6 + tests/foundation-contracts.test.mjs | 14 +++ 22 files changed, 374 insertions(+), 25 deletions(-) create mode 100644 .changeset/segmented-slide.md create mode 100644 tests/browser/segmented-slide.ts diff --git a/.changeset/segmented-slide.md b/.changeset/segmented-slide.md new file mode 100644 index 00000000..78822a13 --- /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 resize; 0ms under `prefers-reduced-motion`). Until a script has measured it — server-rendered first paint, plain HTML — the chosen item keeps drawing its own face. React and Solid render Ark's `SegmentGroup.Indicator`; Vue measures it itself. 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..5771bef7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,6 +5,35 @@ --- +## 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` + 하나를 두고 고른 칸의 상자(`--left/--top/--width/--height`)를 넣는다. 재기 전에는 `hidden`이고, 그동안(서버 렌더의 + 첫 페인트, 스크립트 없는 HTML) 고른 칸이 지금처럼 자기 면을 그린다 — 아무것도 잃지 않는다. 재면 + `:has()`로 칸의 면을 비우고 표시가 면이 된다. React·Solid는 Ark `SegmentGroup.Indicator`(Zag가 재고 바꿀 때만 + 움직인다), Vue는 같은 일을 하는 작은 함수(`useSegmentIndicator`)다. +5. **안드로이드는 행 뒤의 손잡이(thumb) 하나.** 칸은 누름·hover·포커스만 그린다. 포커스 링은 손잡이의 선 위에 놓인다. + +**대안.** ① 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..c7d03b8e 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,40 @@ 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 — 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; + --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 +191,9 @@ .dds-segmented__item { transition: none; } + .dds-segmented__indicator { + --transition-duration: 0ms; + } } @media (pointer: coarse) { diff --git a/packages/dds-react/src/segmented-control.tsx b/packages/dds-react/src/segmented-control.tsx index 23aedd7a..68418fd9 100644 --- a/packages/dds-react/src/segmented-control.tsx +++ b/packages/dds-react/src/segmented-control.tsx @@ -121,6 +121,8 @@ export function SegmentedControl(props: SegmentedControlProps) { ) : null}
+ {/* The chosen face, sliding between choices (D-055). Hidden until measured. */} + {items.map((item) => (
+ {/* The chosen face, sliding between choices (D-055). Hidden until measured. */} + {(item) => ( () for (const item of items) { if (!item.value || values.has(item.value)) throw new Error('DDS segmented control: values must be nonempty and unique'); values.add(item.value) } } +/** + * The segmented control's sliding face (D-055), as Ark's SegmentGroup.Indicator does it for + * React and Solid: measure the chosen item against the track, stay hidden until measured + * (server render, first paint), animate only when the choice changes — a resize or a font + * load moves it without a slide. + */ +function useSegmentIndicator(track: Ref, value: Ref) { + const rect = ref<{ x: number; y: number; width: number; height: number } | null>(null) + const animate = ref(false) + const measure = () => { + const chosen = track.value?.querySelector('.dds-segmented__item[data-state="checked"]') + rect.value = chosen ? { x: chosen.offsetLeft, y: chosen.offsetTop, width: chosen.offsetWidth, height: chosen.offsetHeight } : null + } + let observer: ResizeObserver | undefined + let frame = 0 + onMounted(() => { + measure() + if (typeof ResizeObserver !== 'undefined' && track.value) { + // Measured on the next frame, not inside the callback (that loops the observer). A resize + // follows the box without a slide, but does not cut short a slide already under way. + observer = new ResizeObserver(() => { cancelAnimationFrame(frame); frame = requestAnimationFrame(measure) }) + observer.observe(track.value) + for (const item of Array.from(track.value.children)) observer.observe(item) + } + }) + onBeforeUnmount(() => { observer?.disconnect(); cancelAnimationFrame(frame) }) + watch(value, async () => { await nextTick(); animate.value = rect.value !== null; measure() }) + return () => { + const r = rect.value + return h('span', { + class: 'dds-segmented__indicator', 'aria-hidden': true, hidden: r === null || (r.width === 0 && r.height === 0), + style: { + '--left': `${r?.x ?? 0}px`, '--top': `${r?.y ?? 0}px`, '--width': `${r?.width ?? 0}px`, '--height': `${r?.height ?? 0}px`, + transitionProperty: animate.value ? 'left, top, width, height' : 'none', + transitionDuration: 'var(--transition-duration)', transitionTimingFunction: 'var(--transition-timing-function)', + }, + onTransitionend: (e: TransitionEvent) => { if (e.target === e.currentTarget) animate.value = false }, + }) + } +} function radioSet(name: string, segmented: boolean) { return defineComponent({ name, inheritAttrs: false, props: { label: { type: String, required: true }, items: { type: Array as PropType, default: () => [] }, options: Array as PropType, modelValue: String, defaultValue: { type: String, default: '' }, disabled: Boolean, readOnly: Boolean, required: Boolean, name: String, size: { type: String as PropType<'sm' | 'md'>, default: 'md' }, fullWidth: Boolean }, emits: ['update:modelValue', 'change'], setup(p, { attrs, emit }) { const id = `dds-radio-${useId()}`; const value = useModel(() => p.modelValue, p.defaultValue, v => { emit('update:modelValue', v); emit('change', v) }); const items = computed(() => { const list = p.options ?? p.items; validateSegmentedItems(list); return list }) - return () => h('div', mergeProps(attrs, { class: segmented ? 'dds-segmented' : 'dds-field', role: 'radiogroup', 'aria-labelledby': id, 'aria-required': p.required || undefined, 'data-size': p.size, 'data-full-width': p.fullWidth ? '' : undefined }), [h('span', { id, class: segmented ? 'dds-segmented__label' : 'dds-field__label' }, p.label), h('div', { class: segmented ? 'dds-segmented__track' : 'dds-radio-group' }, items.value.map(item => h('label', { key: item.value, class: segmented ? 'dds-segmented__item' : 'dds-check', 'data-state': value.value === item.value ? 'checked' : 'unchecked', 'data-disabled': p.disabled || item.disabled ? '' : undefined }, [h('input', { type: 'radio', class: segmented ? 'dds-visually-hidden' : 'dds-check__input', name: p.name ?? id, value: item.value, checked: value.value === item.value, disabled: p.disabled || item.disabled, required: p.required, onClick: (e: Event) => { if (p.readOnly) e.preventDefault() }, onChange: () => { if (!p.disabled && !p.readOnly && !item.disabled) value.value = item.value } }), h('span', { class: segmented ? 'dds-segmented__text' : undefined, 'data-text': item.label }, item.label)])))]) + const track = ref(); const indicator = segmented ? useSegmentIndicator(track, value) : undefined + return () => h('div', mergeProps(attrs, { class: segmented ? 'dds-segmented' : 'dds-field', role: 'radiogroup', 'aria-labelledby': id, 'aria-required': p.required || undefined, 'data-size': p.size, 'data-full-width': p.fullWidth ? '' : undefined }), [h('span', { id, class: segmented ? 'dds-segmented__label' : 'dds-field__label' }, p.label), h('div', { ref: track, class: segmented ? 'dds-segmented__track' : 'dds-radio-group' }, [indicator?.(), ...items.value.map(item => h('label', { key: item.value, class: segmented ? 'dds-segmented__item' : 'dds-check', 'data-state': value.value === item.value ? 'checked' : 'unchecked', 'data-disabled': p.disabled || item.disabled ? '' : undefined }, [h('input', { type: 'radio', class: segmented ? 'dds-visually-hidden' : 'dds-check__input', name: p.name ?? id, value: item.value, checked: value.value === item.value, disabled: p.disabled || item.disabled, required: p.required, onClick: (e: Event) => { if (p.readOnly) e.preventDefault() }, onChange: () => { if (!p.disabled && !p.readOnly && !item.disabled) value.value = item.value } }), h('span', { class: segmented ? 'dds-segmented__text' : undefined, 'data-text': item.label }, item.label)]))])]) } }) } diff --git a/tests/browser/components.spec.ts b/tests/browser/components.spec.ts index bc270f14..7ccacd19 100644 --- a/tests/browser/components.spec.ts +++ b/tests/browser/components.spec.ts @@ -1,3 +1,4 @@ +import { expectSlidingFace } from "./segmented-slide"; import { expect, test, type Locator } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; @@ -955,6 +956,9 @@ for (const [framework, port] of [ await page.getByRole("button", { name: "Submit number form" }).click(); await expect(page.getByLabel("Number submission")).toHaveText("month|15000"); }); + test("segmented control's chosen face slides to the new choice (D-055)", async ({ page }) => { + await expectSlidingFace(page.getByRole("radiogroup", { name: "View", exact: true }), "Month"); + }); test("number input formats, steps, clamps on blur, ignores the wheel and submits the plain number", async ({ page }) => { const amount = page.getByRole("spinbutton", { name: "Amount", exact: true }); await expect(amount).toHaveValue("15,000"); diff --git a/tests/browser/segmented-slide.ts b/tests/browser/segmented-slide.ts new file mode 100644 index 00000000..75f32335 --- /dev/null +++ b/tests/browser/segmented-slide.ts @@ -0,0 +1,34 @@ +import { expect, type Locator } from "@playwright/test"; + +/** + * The segmented control's sliding face (D-055), the same in every framework: one indicator + * in the track, on the chosen item's box; the chosen item draws no face of its own once the + * indicator is placed; choosing another item moves the indicator there. + */ +export async function expectSlidingFace(group: Locator, otherLabel: string) { + const indicator = group.locator(".dds-segmented__track > .dds-segmented__indicator"); + await expect(indicator).toHaveCount(1); + await expect(indicator).toBeVisible(); + const box = async (locator: Locator) => (await locator.boundingBox())!; + const near = (a: number, b: number) => Math.abs(a - b) <= 1; + const sameBox = async (item: Locator) => { + await expect.poll(async () => { + const [i, c] = [await box(indicator), await box(item)]; + return near(i.x, c.x) && near(i.width, c.width) && near(i.y, c.y) && near(i.height, c.height); + }).toBe(true); + }; + const checked = group.locator('.dds-segmented__item[data-state="checked"]'); + await sameBox(checked); + // One face: the chosen item's own background and edge give way to the indicator. + expect(await checked.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe("rgba(0, 0, 0, 0)"); + expect(await indicator.evaluate((el) => getComputedStyle(el).boxShadow)).not.toBe("none"); + const other = group.locator(".dds-segmented__item", { hasText: otherLabel }); + await other.click(); + await expect(other).toHaveAttribute("data-state", "checked"); + // It travels rather than jumps: a change of choice runs a transition on its position. The + // transition lasts 200ms and is switched off when it ends, so it is watched for, not sampled. + await expect + .poll(() => indicator.evaluate((el) => getComputedStyle(el).transitionProperty), { intervals: [10], timeout: 2_000 }) + .toContain("left"); + await sameBox(other); +} diff --git a/tests/browser/vue.spec.ts b/tests/browser/vue.spec.ts index 65b91c0f..b44bb4ee 100644 --- a/tests/browser/vue.spec.ts +++ b/tests/browser/vue.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test' import AxeBuilder from '@axe-core/playwright' import { registerToastChecks } from './toast-checks' +import { expectSlidingFace } from './segmented-slide' registerToastChecks('Vue', '/') test('Vue controls, owned overlays and native Community grid interactions', async ({ page }) => { @@ -55,6 +56,11 @@ test('Vue controls, owned overlays and native Community grid interactions', asyn await expect(grid.getByRole('gridcell', { name: '100', exact: true })).toHaveCount(0) expect(errors).toEqual([]) }) +test("Vue segmented control's chosen face slides to the new choice (D-055)", async ({ page }) => { + await page.goto('/') + await expectSlidingFace(page.getByRole('radiogroup', { name: '밀도', exact: true }), '촘촘하게') +}) + test('Vue showcase is accessible and does not overflow at desktop widths', async ({ page }) => { await page.goto('/') await expect(page.getByRole('grid', { name: '샘플 거래처' })).toBeVisible() diff --git a/tests/foundation-contracts.test.mjs b/tests/foundation-contracts.test.mjs index e120765b..d1c3d88e 100644 --- a/tests/foundation-contracts.test.mjs +++ b/tests/foundation-contracts.test.mjs @@ -443,3 +443,17 @@ test("bg.raised is lighter than bg.subtle and text reads on it, and the chosen s const segmented = await read("packages/dds-css/src/segmented-control.css"); assert.match(segmented, /\.dds-segmented__item\[data-state="checked"\]\s*\{[^}]*background:\s*var\(--dds-color-bg-raised\)/); }); + +test("the chosen segment's face slides on base motion and stops under reduced motion (D-055)", async () => { + const css = await read("packages/dds-css/src/segmented-control.css"); + assert.match(css, /\.dds-segmented__indicator\s*\{[^}]*--transition-duration:\s*var\(--dds-motion-duration-base\)/); + assert.match(css, /\.dds-segmented__indicator\s*\{[^}]*--transition-timing-function:\s*var\(--dds-motion-easing-standard\)/); + assert.match(css, /prefers-reduced-motion: reduce\)\s*\{[^@]*\.dds-segmented__indicator\s*\{\s*--transition-duration:\s*0ms/); + // Until measured, the chosen item keeps its own face; once placed, the indicator is the face. + assert.match(css, /\.dds-segmented__indicator\[hidden\]\s*\{\s*display:\s*none/); + assert.match(css, /:has\(> \.dds-segmented__indicator:not\(\[hidden\]\)\)\s*\.dds-segmented__item\[data-state="checked"\]\s*\{[^}]*background:\s*transparent/); + for (const file of ["packages/dds-react/src/segmented-control.tsx", "packages/dds-solid/src/segmented-control.tsx"]) { + assert.match(await read(file), / Date: Fri, 13 Nov 2026 16:16:01 +0900 Subject: [PATCH 2/2] fix(segmented): measure the sliding face ourselves and skip it without ResizeObserver CI's Solid hydration check failed with "win.ResizeObserver is not a constructor": Ark's SegmentGroup.Indicator makes Zag observe the chosen item unconditionally, through the element's own window. Zag offers no switch for it, and it measures only at start and on a value change, so mounting the indicator later would leave it unplaced. A test-only polyfill would have hidden the same crash in consumers' jsdom tests (the owner asked not to cover it that way). One small DOM function, attachSegmentIndicator, now does the measuring for React, Solid and Vue (the same file in each package; a contract test keeps them identical). Without ResizeObserver it does nothing: the indicator stays hidden and the chosen item paints its own face, so servers, jsdom tests and old browsers lose only the slide. The server and the first client render are the same hidden span, so hydration is unchanged. The fixtures keep their original stubs, so the release checks now run the guarded path. The slide is switched off on transitionend rather than on any resize: in Vue a resize notice arrives right after the click and was cancelling the slide (the local Vue browser run caught it). --- .changeset/segmented-slide.md | 2 +- docs/decisions.md | 17 +++-- packages/dds-css/src/segmented-control.css | 12 ++-- .../src/__tests__/segmented-control.test.tsx | 54 +++++++++++++++ packages/dds-react/src/segmented-control.tsx | 9 ++- packages/dds-react/src/segmented-indicator.ts | 65 +++++++++++++++++++ .../src/__tests__/segmented-control.test.tsx | 22 +++++++ packages/dds-solid/src/segmented-control.tsx | 13 +++- packages/dds-solid/src/segmented-indicator.ts | 65 +++++++++++++++++++ .../dds-vue/src/__tests__/expanded.test.ts | 21 ++++++ packages/dds-vue/src/controls.ts | 50 ++++---------- packages/dds-vue/src/segmented-indicator.ts | 65 +++++++++++++++++++ tests/foundation-contracts.test.mjs | 21 ++++-- 13 files changed, 356 insertions(+), 60 deletions(-) create mode 100644 packages/dds-react/src/segmented-indicator.ts create mode 100644 packages/dds-solid/src/segmented-indicator.ts create mode 100644 packages/dds-vue/src/segmented-indicator.ts diff --git a/.changeset/segmented-slide.md b/.changeset/segmented-slide.md index 78822a13..9dc1dea5 100644 --- a/.changeset/segmented-slide.md +++ b/.changeset/segmented-slide.md @@ -5,4 +5,4 @@ "@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 resize; 0ms under `prefers-reduced-motion`). Until a script has measured it — server-rendered first paint, plain HTML — the chosen item keeps drawing its own face. React and Solid render Ark's `SegmentGroup.Indicator`; Vue measures it itself. +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/docs/decisions.md b/docs/decisions.md index 5771bef7..11ac73bc 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -19,14 +19,19 @@ 2. **옮겨 갈 때만 움직인다.** 처음 그릴 때, 같은 선택이 자리만 바뀔 때(회전, 글자 크기, 창 크기)는 움직이지 않고 그 자리에 놓인다. 첫 프레임부터 고른 칸 위에 있다. 3. **움직임 줄이기를 따른다.** 웹은 `prefers-reduced-motion`에서 0ms, 안드로이드는 기기의 애니메이션 끄기에서 바로. -4. **웹은 스크립트가 잴 때까지 칸이 자기 면을 그린다.** 프레임워크 패키지가 트랙 안에 `.dds-segmented__indicator` - 하나를 두고 고른 칸의 상자(`--left/--top/--width/--height`)를 넣는다. 재기 전에는 `hidden`이고, 그동안(서버 렌더의 - 첫 페인트, 스크립트 없는 HTML) 고른 칸이 지금처럼 자기 면을 그린다 — 아무것도 잃지 않는다. 재면 - `:has()`로 칸의 면을 비우고 표시가 면이 된다. React·Solid는 Ark `SegmentGroup.Indicator`(Zag가 재고 바꿀 때만 - 움직인다), Vue는 같은 일을 하는 작은 함수(`useSegmentIndicator`)다. +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·포커스만 그린다. 포커스 링은 손잡이의 선 위에 놓인다. -**대안.** ① CSS 앵커 위치 지정(`anchor-name`) — 스크립트가 필요 없지만 Firefox가 아직 지원하지 않고, 앵커가 +**대안.** ⓪ Ark `SegmentGroup.Indicator` — 위 4번. ① CSS 앵커 위치 지정(`anchor-name`) — 스크립트가 필요 없지만 Firefox가 아직 지원하지 않고, 앵커가 바뀔 때의 트랜지션이 브라우저마다 다르며, 한 페이지의 여러 컨트롤에 `anchor-scope`가 필요하다. ② 지금처럼 색만 — 소유자가 미끄러지는 쪽을 골랐다. ③ 300ms(`slow`) — 탭 밑줄과 다른 속도가 된다. diff --git a/packages/dds-css/src/segmented-control.css b/packages/dds-css/src/segmented-control.css index c7d03b8e..1066945a 100644 --- a/packages/dds-css/src/segmented-control.css +++ b/packages/dds-css/src/segmented-control.css @@ -132,7 +132,8 @@ * 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 — the chosen item draws its own face (above), so nothing is lost. + * 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 { @@ -149,8 +150,11 @@ background: var(--dds-color-bg-raised); box-shadow: var(--dds-elevation-1); pointer-events: none; - --transition-duration: var(--dds-motion-duration-base); - --transition-timing-function: var(--dds-motion-easing-standard); + /* 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; @@ -192,7 +196,7 @@ transition: none; } .dds-segmented__indicator { - --transition-duration: 0ms; + transition-duration: 0ms; } } 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 68418fd9..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,9 +125,9 @@ export function SegmentedControl(props: SegmentedControlProps) { {label} ) : null} -
+
{/* The chosen face, sliding between choices (D-055). Hidden until measured. */} - +