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#
코드 보기
class JoinDefaultExample extends StatelessComponent {
const JoinDefaultExample({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'),
),
],
);
}
}
class JoinDefaultExample extends StatelessWidget {
const JoinDefaultExample({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'),
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 여러 버튼을 하나의 그룹처럼 붙여 툴바를 구성할 때
- 입력 필드와 버튼을 하나의 검색 바로 결합할 때
- 서로 연관된 버튼들이 독립적이지 않고 하나의 컨트롤로 인식되어야 할 때
대신 다른 컴포넌트를 사용하세요:
- Gap: 모서리를 이어 붙이지 않고 자식 사이 공간만 필요할 때
Row/Column: 각 자식이 독립된 모서리를 유지해야 할 때. Join은 외곽 반경 clip만 다르다
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. List<W>
는 콘텐츠 목록입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
children | List<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>에configureAttributesoverride 가 없어 컴포넌트가 강제하는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)#
| 항목 | Flutter | Web |
|---|---|---|
| 렌더링 |
IntrinsicHeight
/
IntrinsicWidth
+
Row
/
Column
(
crossAxisAlignment: .stretch
) + 자식별
ClipRRect
|
<div>
flex strip + inline
border-radius
+
overflow: hidden
|
| 자식 radius 평탄화 | ambient JoinItemData (참여 컴포넌트가 스스로 평탄화) + ClipRRect fallback |
scoped CSS 규칙이 직계 자식 루트의 border-radius 를 덮어씀 |