Join | CoUI
LogoCoUI

Join

여러 요소를 구분자 없이 시각적으로 결합하는 레이아웃 컴포넌트

Join#

버튼·입력 필드 등 여러 요소를 동일한 외곽 반경으로 묶어 하나의 툴바/그룹처럼 보이게 하는 레이아웃 컴포넌트입니다.

Row / Column은 각 자식의 모서리를 그대로 두지만, Join첫 번째 아이템만 시작 측 radius, 마지막 아이템만 끝 측 radius, 중간 아이템은 radius 0으로 자식의 모서리를 평탄화합니다. 외곽 코너는 자식이 아닌 Join 의 radius 를 따르며, 결과적으로 버튼 그룹의 외곽만 둥글고 내부는 평평하게 이어진 시각적 단위가 됩니다.

자식 radius 평탄화 범위#

  • Web: 모든 직계 자식의 루트 요소에 position-aware border-radius 가 강제됩니다 (scoped CSS).
  • Flutter: 통일 컴포넌트가 ambient JoinItemData 를 읽어 스스로 평탄화합니다 — Button · TextField 가 참여하고, Select 는 트리거가 Button 합성이라 자동 참여합니다. 참여하지 않는 임의 위젯은 clip 으로 근사되어 자식이 스스로 그린 둥근 모서리가 이음새에 남을 수 있습니다. 새 radius-bearing 통일 컴포넌트는 resolver 에서 JoinItemData 를 채택해 참여합니다.

보더 두께 자체를 겹치거나 제거하는 처리(border-collapse 동작)는 하지 않습니다. 각 자식은 자신의 보더를 그대로 가진 채 외곽 반경만 통일됩니다.

Live Preview#

사용 시기 (When to Use)#

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

  • 여러 버튼을 하나의 그룹처럼 붙여 툴바를 구성할 때
  • 입력 필드와 버튼을 하나의 검색 바로 결합할 때
  • 서로 연관된 버튼들이 독립적이지 않고 하나의 컨트롤로 인식되어야 할 때

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

  • Gap: 요소 사이에 여백만 필요할 때
  • Row / Column: 각 자식이 독립된 모서리를 유지해야 할 때. Join은 외곽 반경 clip만 다르다

기본 사용법 (Basic Usage)#

// 수평 버튼 그룹 (기본).
Join(
  children: [
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('1')),
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('2')),
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('3')),
  ],
)

// 수직 결합.
Join(
  vertical: true,
  children: [
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('A')),
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('B')),
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('C')),
  ],
)

Props / Parameters#

속성타입기본값설명
children List<Widget> / List<Component> 필수 결합할 위젯 목록
vertical bool false true이면 수직 결합
joinStyle CoreJoinStyle? null 외곽 모서리 반경 chrome override

스타일 시스템 (Style System)#

모든 chrome / dimensional override 는 joinStyle 슬롯 하나로 흐릅니다.

CoreJoinStyle 필드#

필드타입설명
borderRadius CoreBorderRadius? Outer border radius for the first / last joined items.

Resolve chain#

design system default (CoreRadius.radius4)
  → CoreJoinTheme.style                           // 프로젝트 공통
  → parent component slot override
  → widget.joinStyle                              // 인스턴스별

빠른 오버라이드 (Chain)#

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

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

  @override
  Widget build(BuildContext context) {
    return Join(
          children: [
            Button(
              variant: CoreButtonVariant.outline,
              onPressed: () {},
              child: const Text('1'),
            ),
            Button(
              variant: CoreButtonVariant.outline,
              onPressed: () {},
              child: const Text('2'),
            ),
            Button(
              variant: CoreButtonVariant.outline,
              onPressed: () {},
              child: const Text('3'),
            ),
          ],
        )
        .withStyle(
          const CoreJoinStyle(
            borderRadius: CoreBorderRadius.all(CoreRadius.radius4),
          ),
        )
        .radius16;
  }
}
class JoinChainExample extends StatelessComponent {
  const JoinChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return Join(
          children: [
            Button(
              variant: CoreButtonVariant.outline,
              onPressed: () {},
              child: Text('1'),
            ),
            Button(
              variant: CoreButtonVariant.outline,
              onPressed: () {},
              child: Text('2'),
            ),
            Button(
              variant: CoreButtonVariant.outline,
              onPressed: () {},
              child: Text('3'),
            ),
          ],
        )
        .withStyle(
          const CoreJoinStyle(
            borderRadius: CoreBorderRadius.all(CoreRadius.radius4),
          ),
        )
        .radius16;
  }
}

변형 (Variants)#

수평 (기본)#

Join(
  children: [
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('1')),
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('2')),
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('3')),
  ],
)

수직#

Join(
  vertical: true,
  children: [
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('A')),
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('B')),
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('C')),
  ],
)

동작 스펙 (Behavior)#

인터랙션#

  • Join 컨테이너 자체는 인터랙션 없음
  • 각 자식 위젯이 독립적으로 인터랙션 처리
  • 첫 번째/마지막 아이템만 외곽 radius 유지. 중간 아이템은 radius 0으로 이어 붙여진 느낌 제공

상태 전환#

  • Join 컨테이너 자체 상태 없음
  • 자식 버튼의 hover/pressed 상태는 각 버튼이 독립 처리

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

✅ Do — 의미적으로 연관된 버튼들만 묶기#

Join(
  children: [
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('굵게')),
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('기울임')),
    Button(variant: CoreButtonVariant.outline, onPressed: () {}, child: const Text('밑줄')),
  ],
)

❌ Don't — 관련 없는 버튼을 묶지 않기#

Join(
  children: [
    Button(variant: CoreButtonVariant.primary, onPressed: () {}, child: const Text('저장')),
    Button(variant: CoreButtonVariant.destructive, onPressed: () {}, child: const Text('삭제')),
  ],
)

관련 없는 버튼을 하나로 묶으면 사용자가 기능 관계를 잘못 이해할 수 있습니다.

접근성 (Accessibility)#

키보드 인터랙션#

동작
TabJoin 내 다음 포커스 가능 요소로 이동
Enter / Space포커스된 버튼 활성화

터치 타겟#

  • 각 자식 버튼은 최소 터치 타겟(24×24, CoreTouchTarget.minimum)을 보장합니다.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
렌더링 IntrinsicHeight / IntrinsicWidth + Row / Column ( crossAxisAlignment: .stretch ) + 자식별 ClipRRect <div> flex strip + inline border-radius + overflow: hidden
자식 radius 평탄화 ambient JoinItemData (참여 컴포넌트가 스스로 평탄화) + ClipRRect fallback scoped CSS 규칙이 직계 자식 루트의 border-radius 를 덮어씀
  • Gap: 요소 사이에 단순 여백을 추가할 때 사용
  • Divider: 구분선이 필요할 때 사용