Join | CoUI
본문으로 건너뛰기
LogoCoUI

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 를 채택해 참여합니다.
  • 포커스 링: 평탄화는 박스와 포커스 링에 함께 적용되어, 가운데 항목의 링도 이음새 쪽이 각집니다. Web 은 링이 자식 루트의 box-shadow 라 덮어쓴 border-radius 를 그대로 따르고, Flutter 는 JoinItemData.coreBorderRadius(스케일 전 값)를 링에 넘겨 FocusOutline 이 한 번만 스케일합니다 — 박스용 JoinItemData.borderRadius 는 이미 스케일된 값이라 링에 넘기면 두 번 스케일됩니다.

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

기본 사용법 (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')),
  ],
)

Live Preview#

사용 시기 (When to Use)#

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

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

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

  • Gap: 모서리를 이어 붙이지 않고 자식 사이 공간만 필요할 때
  • Row / Column: 각 자식이 독립된 모서리를 유지해야 할 때. Join은 외곽 반경 clip만 다르다

Props / Parameters#

W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. List<W> 는 콘텐츠 목록입니다.

속성타입기본값설명
childrenList<W>필수결합할 위젯 목록
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 컨테이너 자체 상태 없음
  • 선택·활성 상태를 그룹에서 바꾸지 않습니다. Flutter의 내부 포커스 추적은 겹친 링의 그리기 순서를 정할 뿐, 항목 이동이나 그룹 키보드 단축키를 등록하지 않습니다.
  • 자식 버튼의 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)#

역할#

어느 플랫폼도 Join 자신을 위한 역할을 emit 하지 않습니다 — 묶음은 시각적 표현이고, 그 안의 자식들이 각자 자기 역할을 유지합니다.

  • Flutter: join.dart 전체에 Semantics 가 없습니다. ClipRRect · Data.inherit · Row/Column 만 쌓으므로 시맨틱 트리에는 자식들이 형제로 나란히 노출됩니다. 묶음 자체를 설명해야 하면 호출자가 Semantics(container: true, label: ...) 로 감싸세요.
  • Web: 루트 <div> 에 configureAttributes override 가 없어 컴포넌트가 강제하는 role / aria-* 가 없고, attributes: componentAttributes 로 호출자 속성이 그대로 통과합니다. 툴바나 라디오 묶음처럼 읽혀야 하면 Join(attributes: {'role': 'group', 'aria-label': '...'}) 로 직접 실으세요.
  • 루트의 첫 자식은 자식 radius 를 평탄화하는 scoped <style> 요소입니다 — 렌더도 통보도 되지 않습니다.

키보드#

키동작
없음Join 이 처리하는 키가 없습니다

Flutter 에는 Focus / onKeyEvent 가 없고, Web 생성자는 onKeyDown 을 노출조차 하지 않습니다 (super.eventHandlers 만 받습니다). 그래서 Tab 은 자식 선언 순서대로 흐르고 Enter / Space 는 포커스된 자식 자신의 동작으로 갑니다.

항목 사이를 화살표로 옮기는 roving tabindex 는 구현되어 있지 않습니다 — 버튼이 N 개면 묶음을 빠져나가는 데 Tab 을 N 번 눌러야 합니다.

스크린 리더#

접근 가능한 이름의 출처는 전적으로 각 자식입니다. Join 은 이름도 설명도 만들지 않습니다.

상태 통보는 없습니다 — 양 플랫폼 모두 live region 이 없고, Join 은 자식 배치와 모서리 마스킹만 하는 정적 컴포넌트라 통보할 상태가 없습니다.

포커스 관리#

오버레이가 아니므로 진입 · 이탈 · 트랩이 모두 없습니다. Join 은 포커스를 요청하지도, 자식의 traversal 순서를 바꾸지도 않습니다 — 포커스는 자식들을 선언 순서대로 통과합니다.

알려진 제약#

항목 바깥으로 그려지는 것이 이음매에서 잘립니다. Join 은 자식마다 클립을 씌웁니다 — Flutter 는 ClipRRect(borderRadius: itemRadius), Web 은 항목 래퍼의 overflow: hidden. 그런데 포커스 링은 양 플랫폼 모두 자식 박스 바깥에 그려집니다 (Flutter FocusOutline 이 Stacks(clipBehavior: .none) 안에서 Position(top: -align, ...) 으로 띄우고, Web 은 outline 으로 border box 밖에 얹습니다). 그래서 포커스 표시 중 항목 경계를 넘어가는 부분은 지워집니다. 같은 클립이 24px 최소 포인터 타깃의 바깥 확장도 자릅니다 (Web 의 ::after width: max(100%, 24px), Flutter TouchTarget 의 외부 히트 허용) — 24px 보다 작은 자식이 밖으로 넓히려던 만큼은 도달하지 않습니다.

대안: 링 offset 이 큰 자식은 focusOutlineStyle 로 offset 을 줄이거나 묶음 밖에 두고, 24px 미만인 컨트롤은 자기 Style 슬롯으로 실제 크기를 키워 넣으세요. joinStyle.borderRadius 를 키우는 것은 클립 모양만 바꿀 뿐 해결이 아닙니다.

RTL 로케일에서 좌우가 뒤집히지 않습니다. 항목별 모서리 마스킹이 물리 좌우로 고정되어 있습니다 — Flutter 는 radius.resolve(.ltr) 로 방향을 못 박은 뒤 첫 항목에 topLeft/bottomLeft 를 주고, Web 은 첫 항목에 edgeFlushValue('right') 를 준 뒤 :first-of-type 로 선택합니다. 반면 Row 와 flex-row 는 RTL 에서 시각 순서를 뒤집으므로, 첫 항목이 오른쪽 끝에 놓이는데 둥근 모서리는 왼쪽(이음매 쪽)에 남습니다. 기본값이 네 모서리 대칭(CoreRadius.radius4)이어도 발생합니다.

대안: vertical: true 는 영향을 받지 않습니다 — 세로 묶음은 위/아래 모서리를 쓰고 그건 읽기 방향과 무관합니다. 가로 묶음에는 우회로가 없습니다.

크로스 플랫폼 차이점 (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: 구분선이 필요할 때 사용