Diff | CoUI
LogoCoUI

Diff

두 컨텐츠를 좌/우로 비교하는 split-view 컴포넌트 (이미지 / 텍스트 / 임의 위젯)

Diff#

두 개의 컨텐츠를 한 프레임 안에 겹쳐 놓고 가운데 분할선을 드래그해서 좌/우 비율을 조절하면서 비교하는 viewer 입니다. daisyUI 의 diff 컴포넌트와 동일한 사용 의도이며, 이미지·텍스트·임의 위젯을 슬롯으로 받습니다.

코드 변경 사항을 라인 단위로 비교하려면 CodeDiff 를 사용하세요.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 이미지 before/after 비교 (보정 전/후, 라이트/다크, 디자인 시안 비교)
  • 폰트나 디자인 토큰 두 가지를 동일한 텍스트로 비교
  • 어떤 두 컨텐츠든 좌/우 분할선으로 비율을 조절하며 보고 싶을 때

대신 다른 컴포넌트를 사용하세요:

  • CodeDiff: 코드 변경 사항을 라인 단위로 색상 강조 표시할 때
  • Carousel: 여러 컨텐츠를 순차적으로 넘기며 볼 때

빠른 오버라이드 (Chain)#

이미 만든 Diff 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius9999처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius9999 == CoreRadius.radius9999) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class DiffChainExample extends StatelessWidget {
  const DiffChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Diff(
          ariaLabel: 'Sharp vs blurred photo comparison',
          before: Image.network(
            'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a.webp',
            fit: BoxFit.cover,
          ),
          after: Image.network(
            'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a-blur.webp',
            fit: BoxFit.cover,
          ),
        )
        .withStyle(
          const CoreDiffStyle(
            resizerColor: CoreColor.token(CoreColors.tertiary),
            resizerWidth: CoreStrokeWidth.stroke4,
            aspectRatio: 4 / 3,
          ),
        )
        .radius16;
  }
}
class DiffChainExample extends StatelessComponent {
  const DiffChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return const Diff(
          ariaLabel: 'Sharp vs blurred photo comparison',
          before: _DiffImg(
            url: 'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a.webp',
          ),
          after: _DiffImg(
            url: 'https://img.daisyui.com/images/stock/photo-1560717789-0ac7c58ac90a-blur.webp',
          ),
        )
        .withStyle(
          const CoreDiffStyle(
            resizerColor: CoreColor.token(CoreColors.tertiary),
            resizerWidth: CoreStrokeWidth.stroke4,
            aspectRatio: 4 / 3,
          ),
        )
        .radius16;
  }
}

class _DiffImg extends StatelessComponent {
  const _DiffImg({required this.url});

  final String url;

  @override
  Component build(BuildContext context) {
    return img(
      src: url,
      alt: '',
      // `not-content` opts out of the docs `prose` styling which
      // otherwise injects `margin: 2em` and `height: auto` on every
      // `<img>` inside the article body.
      classes: 'not-content',
      styles: const Styles(
        raw: {
          'display': 'block',
          'width': '100%',
          'height': '100%',
          'object-fit': 'cover',
        },
      ),
    );
  }
}

Props / Parameters#

속성타입기본값설명
before Widget / Component 필수 좌측 (before) 슬롯
after Widget / Component 필수 우측 (after) 슬롯
onPositionChanged void Function(double)? null 분할 위치 변경 콜백
interactive bool true 드래그/키보드 인터랙션 활성화
ariaLabel String? null 스크린 리더 라벨
diffStyle CoreDiffStyle? null chrome / dimensional 단일 진입점 (아래 표 참고)

스타일 시스템 (Style System)#

모든 chrome / 치수 / layout override 는 diffStyle 슬롯 하나로 흐릅니다.

CoreDiffStyle 필드#

필드타입설명
borderRadius CoreBorderRadius? Container border radius override.
resizerColor CoreColor? Resizer handle colour override.
resizerWidth double? Resizer handle width override (logical px, pre-scaling).
aspectRatio double? Container aspect ratio (width / height). Visual layout chrome — drives the box dimensions.
initialPosition double? Initial split-bar position (0.0 — 1.0). Visual default chrome applied at first render; runtime drags overwrite it when interactive: true .
keyboardStep double? Position delta applied per arrow-key press ( [0, 1] units) override. Falls back to [defaultKeyboardStep] when null.

Resolve chain#

CoreDiffStyle.defaultX (design system default)
  → CoreDiffTheme.style                           // 프로젝트 공통
  → widget.diffStyle                              // 인스턴스별

인터랙션#

  • 드래그: 분할선(리사이저 핸들)을 좌우로 드래그해서 비율 조정. 커서는 resizeColumn 으로 바뀝니다
  • 키보드: 컴포넌트가 포커스된 상태에서 ←/→ 키로 diffStyle.keyboardStep (기본 5%) 만큼 이동
  • interactive: false 면 드래그·키보드 모두 비활성. 프레임 본문을 탭해서 한쪽을 드러내는 동작은 없습니다 — 조작 지점은 분할선뿐입니다

사용 가이드라인 (Usage Guidelines)#

✅ Do#

항상 ariaLabel 지정

Diff(
  ariaLabel: 'Filled vs surface text comparison',
  before: before,
  after: after,
)

기본값이 null이라 지정하지 않으면 인터랙티브 상태에서 값(aria-valuenow)만 있고 이름은 없는 슬라이더가 됩니다.


❌ Don't#

셋 이상의 콘텐츠를 순차적으로 넘겨보는 용도로 사용하지 않기

// ❌ Diff 하나로 여러 이미지를 순회하려는 시도
Diff(before: image1, after: image2) // image3, image4는 넣을 슬롯이 없음

Diff는 정확히 두 콘텐츠(before / after)만 좌우로 비교하는 컴포넌트입니다 — 여러 콘텐츠를 순차적으로 넘겨봐야 한다면 Carousel을 사용하세요.

접근성 (Accessibility)#

Diff 의 핵심 인터랙션은 0.0 ~ 1.0 사이의 분할 위치이고, interactive 일 때 그 값이 양 플랫폼 모두에서 슬라이더 값으로 노출됩니다. interactive: false 면 값 없는 그룹/컨테이너로 남습니다 — 움직이지 않는 슬라이더는 없는 컨트롤을 있다고 알리는 셈이기 때문입니다.

역할 (Semantics)#

interactive 여부로 갈립니다.

  • Flutter (interactive: true): CoUISemantics(role: CoreSemanticRole.slider, value: …, minValue: '0', maxValue: '100', container: true). Flutter 에는 네이티브 slider role 이 없어 Semantics(slider: true) 플래그로 표현됩니다.
  • Flutter (interactive: false): 역할 없는 컨테이너 노드(container: true + label)이고 값 관련 필드는 전부 넘기지 않습니다.
  • Web (interactive: true): role="slider" + aria-valuemin="0" / aria-valuemax="100" / aria-valuenow / aria-valuetext.
  • Web (interactive: false): role="group", 값 속성 없음.

값 단위는 분할 위치를 0 ~ 100 백분율로 반올림한 정수입니다 (aria-valuenow, Flutter value 둘 다 같은 수). 읽히는 문장은 diffPositionLabel(percent) 로케일 멤버에서 오며 두 쪽을 함께 말합니다 — 예: 이전 40%, 이후 60%.

Web 은 호출자 attributes먼저 병합하므로 하드코딩된 role / aria-value* 가 호출자가 넘긴 같은 키를 덮습니다. aria-labelariaLabelnull 이면 키 자체를 쓰지 않아 호출자 attributesaria-label 이 남습니다.

키보드#

동작
분할선을 diffStyle.keyboardStep(기본 0.05)만큼 왼쪽으로
분할선을 diffStyle.keyboardStep(기본 0.05)만큼 오른쪽으로

이 둘이 전부입니다 — Home / End / PageUp / PageDown 도, Shift 조합의 큰 단위 이동도 없습니다. interactive: false 면 Flutter 는 키 이벤트를 무시하고, Web 은 키 핸들러 자체를 붙이지 않습니다.

포커스#

  • Flutter: Focus(focusNode:) 가 기본 canRequestFocus 로 만들어져 interactive 값과 무관하게 항상 탭으로 도달합니다. interactive: false 일 때도 포커스는 받지만 모든 키를 무시합니다.
  • Web: interactive 일 때만 tabindex="0" 이 붙습니다 — 비인터랙티브 Diff 는 포커스 대상이 아닙니다.

즉 같은 설정에서 탭 정지 개수가 플랫폼마다 다릅니다. 그리고 어느 플랫폼도 포커스 표시를 그리지 않습니다(포커스 링 없음). 트랩·복원·autofocus 도 없습니다.

스크린 리더#

인터랙티브 Diff 는 슬라이더로 읽히고 현재 분할 위치가 값으로 함께 읽힙니다 (이름은 ariaLabel 을 넘겼을 때만). 비인터랙티브 Diff 는 Web 에서 "group", Flutter 에서 역할 없는 컨테이너로 읽히고 값은 없습니다.

값 갱신은 포커스된 슬라이더 노드의 값 속성이 매 변경마다 다시 그려지는 것에 의존합니다 — 별도 live region 이나 명시적 announce 호출은 없습니다. 그래서 포커스가 Diff 에 없는 상태(예: 다른 요소에 포커스를 둔 채 드래그)에서 일어난 변경은 안내되지 않습니다.

알려진 제약#

  • 끝점·큰 단위 이동이 없습니다Home / End / PageUp / PageDown 이 없어 슬라이더 관례가 기대하는 "양 끝으로 즉시" 이동을 키보드로 할 수 없습니다.
  • Flutter 에 증가/감소 semantics action 이 없습니다onIncrease / onDecrease 를 노출하지 않으므로 터치 스크린 리더의 슬라이더 조정 제스처로는 값이 바뀌지 않습니다. 실제 이동 경로는 물리 방향키와 드래그뿐입니다.
  • 포커스 링이 없어 키보드 사용자가 Diff 에 포커스가 왔는지 볼 수 없습니다.
  • interactive: false 의 포커스 동작이 플랫폼마다 다릅니다 — Flutter 는 여전히 포커스를 받고 Web 은 받지 않습니다.
  • 기본 상태에서는 이름이 없습니다. ariaLabel 이 유일한 이름 경로이고 기본값이 null 이라, 그대로 쓰면 값만 있고 이름이 없는 슬라이더가 됩니다 — 예제처럼 ariaLabel 을 항상 넘기는 것을 권장합니다.
  • 드래그 리사이저 핸들 자체는 역할도 이름도 없는 요소입니다. 값을 나르는 것은 바깥 컨테이너 노드이고, 핸들은 포인터 조작 지점일 뿐입니다.
  • interactive 는 키 입력·(Web 의) tabindex·역할 선택만 제어할 뿐 aria-disabled / readonly 로 노출되지 않습니다 — 비인터랙티브 Diff 는 "비활성 슬라이더"가 아니라 그냥 그룹/컨테이너가 됩니다.

전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.