Fieldset
관련된 폼 필드를 그룹으로 묶는 컨테이너 컴포넌트
목차
관련된 폼 요소들을 논리적으로 그룹화하고 시각적으로 구분하는 컨테이너 컴포넌트입니다. Flutter/Web 양쪽에서 동일한 API(Fieldset)를 제공합니다.
기본 사용법 (Basic Usage)#
// 기본 (legend + children)
Fieldset(
legend: Text('Account'),
children: [
TextField(placeholder: Text('Name')),
TextField(placeholder: Text('Email')),
],
)
// 비활성화
Fieldset(
legend: Text('Payment'),
enabled: false,
children: [
TextField(placeholder: Text('Card number')),
],
)
// 기본 — Web 의 `Text` 는 jaspr Text 별칭이라
// Flutter 와 동일한 Text chain (`.bodyMedium.onSurface` 등) 을 사용 가능.
Fieldset(
legend: const Text('Account'),
children: [
TextField(placeholder: const Text('Name')),
TextField(placeholder: const Text('Email')),
],
)
// 비활성화
Fieldset(
legend: const Text('Payment'),
enabled: false,
children: [
TextField(placeholder: const Text('Card number')),
],
)
빠른 오버라이드 (Chain)#
이미 만든 Fieldset 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 ==
CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class FieldsetChainExample extends StatelessWidget {
const FieldsetChainExample({super.key});
@override
Widget build(BuildContext context) {
// Fixed-width wrappers so the preview matches Web's
// `w-${CoreSpace.scale.space256}` wrapper — the docs preview
// pane is already centred, we just need a known cross-axis
// size to keep both platforms visually identical.
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
SizedBox(
width: CoreSpace.space256,
child: Fieldset(
legend: const Text('Account'),
children: [
TextField(
autofillHints: const [AutofillHints.name],
placeholder: const Text('Name'),
),
TextField(
autofillHints: const [AutofillHints.email],
placeholder: const Text('Email'),
),
],
).radius16.primary,
),
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
SizedBox(
width: CoreSpace.space256,
child:
Fieldset(
legend: const Text('Account'),
children: [
TextField(
autofillHints: const [AutofillHints.name],
placeholder: const Text('Name'),
),
TextField(
autofillHints: const [AutofillHints.email],
placeholder: const Text('Email'),
),
],
).withStyle(
const CoreFieldsetStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
fieldSpacing: CoreSpace.space12,
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
),
],
);
}
}
class FieldsetChainExample extends StatelessComponent {
const FieldsetChainExample({super.key});
@override
Component build(BuildContext context) {
// Fixed-width wrappers keep the preview at the same width as
// Flutter's `SizedBox(width: CoreSpace.space256)` — the docs
// preview pane uses `align-items: center` for cross-platform
// visual parity, so we anchor a known size instead of letting
// the fieldset stretch to the pane's full inline-axis.
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
div(
[
Fieldset(
legend: const Text('Account'),
children: [
TextField(
autofillHints: const ['name'],
placeholder: const Text('Name'),
),
TextField(
autofillHints: const ['email'],
placeholder: const Text('Email'),
),
],
).radius16.primary,
],
classes: 'w-${CoreSpace.scale.space256}',
),
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(padding)까지 한 번에.
div(
[
Fieldset(
legend: const Text('Account'),
children: [
TextField(
autofillHints: const ['name'],
placeholder: const Text('Name'),
),
TextField(
autofillHints: const ['email'],
placeholder: const Text('Email'),
),
],
).withStyle(
const CoreFieldsetStyle(
backgroundColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
fieldSpacing: CoreSpace.space12,
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space8,
),
),
),
],
classes: 'w-${CoreSpace.scale.space256}',
),
],
classes: 'flex flex-col items-start',
);
}
}
Live Preview#
코드 보기
class FieldsetDefaultExample extends StatelessComponent {
const FieldsetDefaultExample({super.key});
@override
Component build(BuildContext context) {
return div(
[
Fieldset(
legend: const Text('Account'),
children: [
TextField(
autofillHints: const ['name'],
placeholder: const Text('Name'),
),
TextField(
autofillHints: const ['email'],
placeholder: const Text('Email'),
),
],
),
],
classes: 'w-${CoreSpace.scale.space256}',
);
}
}
class FieldsetDefaultExample extends StatelessWidget {
const FieldsetDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: CoreSpace.space256,
child: Fieldset(
legend: const Text('Account'),
children: [
TextField(
autofillHints: const [AutofillHints.name],
placeholder: const Text('Name'),
),
TextField(
autofillHints: const [AutofillHints.email],
placeholder: const Text('Email'),
),
],
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 긴 폼을 주제별로 그룹화하여 시각적으로 구분해야 할 때 (기본 정보, 결제 정보, 배송 정보 등)
- 관련된 폼 필드들에 공통 제목(legend)이 필요할 때
- 특정 조건에서 관련 필드 그룹 전체를 비활성화해야 할 때
대신 다른 컴포넌트를 사용하세요:
- Form: 필드 그룹 표시 대신 폼 전체의 검증·컨트롤러 스코프가 필요할 때
- TextField: 그룹 없이 단일 문자열 입력만 필요할 때
- Card: 폼의 disabled 스코프 없이 일반 콘텐츠 표면이 필요할 때
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. children은 같은 플랫폼 콘텐츠 목록입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
legend | W? | null | 상단 제목 |
children |
List<W> |
[] |
그룹화할 자식 요소 |
enabled |
bool |
true |
그룹 비활성 스코프 제공 |
size |
CoreComponentSize |
md |
간격·제목 크기 단계 |
fieldsetStyle |
CoreFieldsetStyle? |
null |
chrome 단일 진입점 |
CoreFieldsetStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
padding | CoreEdgeInsets? | Inner padding. |
fieldSpacing |
double? |
Spacing between child widgets within the fieldset (logical px). |
borderRadius |
CoreBorderRadius? |
Outer border radius. |
borderWidth |
double? |
Outer border stroke width (logical px). |
backgroundColor |
CoreColor? |
Container background colour. |
borderColor |
CoreColor? |
Outer border stroke colour. |
legendPadding |
CoreEdgeInsets? |
Padding applied around the legend widget. null defers to [defaultLegendPadding]. |
legendTextStyle |
CoreTextStyle? |
Legend text style override. |
disabledLegendColor |
CoreColor? |
Disabled-state legend colour override. null → [defaultDisabledLegendColor]. |
스타일 시스템 (Style System)#
테마 커스터마이징 (Theme)#
CoreFieldsetTheme은 style 슬롯 하나를 가지며, 프로젝트 공통 chrome 이 그 슬롯으로 흐릅니다.
CoreComponentTheme(
fieldset: CoreFieldsetTheme(
style: CoreFieldsetStyle(
padding: CoreEdgeInsets.all(CoreSpace.space16),
fieldSpacing: CoreSpace.space12,
borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
borderWidth: CoreStrokeWidth.stroke1,
borderColor: CoreColor.token(CoreColors.outline),
),
),
)
Resolve 우선순위: widget.fieldsetStyle > CoreFieldsetTheme.style
> CoreFieldsetStyle.default*.
동작 스펙 (Behavior)#
시각#
- 테두리가 있는 컨테이너 + 상단에 legend 텍스트
enabled: false→ opacity 50%-
enabled: false→ 자식 전체에 비활성FormFieldScope를 진술. 안쪽FormField와 스코프를 읽는 입력은 자기enabled: true와 무관하게 비활성이 됩니다 (HTML<fieldset disabled>의미론). 중첩된Fieldset도 바깥의 비활성을 이어받습니다 -
스코프를 읽는 입력은
TextField와,TextField를 합성하는 입력(Select트리거 ·Autocomplete·ChipInput등)입니다 — 합성한TextField가 스코프를 읽으므로 그 입력도 함께 비활성이 됩니다. 그 입력이 여는 팝오버·다이얼로그 안의 콘텐츠는 스코프를 받지 않습니다
레이아웃#
- 자식들은 세로로 배치되며
fieldSpacing간격 유지 - 양쪽 모두 자식 필드가 가용 너비를 채우는 stretch 배치입니다. legend가 없으면 제목 래퍼를 생략합니다. 그룹 자체는 값 제출·검증 콜백을 실행하지 않습니다
사용 가이드라인 (Usage Guidelines)#
✅ Do#
legend로 그룹 제목을 명시
Fieldset(
legend: Text('결제 정보'),
children: [
TextField(placeholder: Text('카드 번호')),
TextField(placeholder: Text('만료일')),
],
)
legend는 시맨틱 제목으로 스크린 리더에 전달되어, 관련 필드들을 하나의 그룹으로 인지시킵니다.
❌ Don't#
enabled: false가 모든 종류의 입력을 막는다고 가정하지 않기
// ❌ TextField 는 막히지만, 스코프를 아직 읽지 않는 Checkbox 는 여전히 조작 가능
Fieldset(
legend: Text('결제 정보'),
enabled: false,
children: [
TextField(placeholder: Text('카드 번호')),
Checkbox(/* ... */),
],
)
enabled: false는 자식에 비활성 FormFieldScope 를 진술하고, 그것을 읽는 컴포넌트만 비활성이 됩니다 — FormField, TextField, 그리고 TextField 를 합성하는 입력(Select 트리거 · Autocomplete · ChipInput 등). TextField 를 합성하지 않는 입력(Checkbox · RadioGroup · TextArea · DatePicker 트리거 등)은 스코프를 받지 못해 50% 투명도만 받으므로, 그런 입력은 각자 비활성화 파라미터를 지정하세요.
✅ Do#
긴 폼은 주제별 Fieldset으로 나눠 스캔하기 쉽게 만들기
Column(
children: [
Fieldset(legend: Text('기본 정보'), children: basicFields),
Fieldset(legend: Text('결제 정보'), children: paymentFields),
Fieldset(legend: Text('배송 정보'), children: shippingFields),
],
)
주제별로 나누면 사용자가 긴 폼에서 원하는 섹션을 빠르게 찾을 수 있습니다.
❌ Don't#
폼 컨텍스트가 아닌 일반 콘텐츠 그룹에 쓰지 않기
// ❌ 폼 필드가 아닌 일반 콘텐츠를 묶는 데 Fieldset 사용
Fieldset(
legend: Text('추천 상품'),
children: [ProductCard(item: a), ProductCard(item: b)],
)
Fieldset은 폼 필드 그룹화를 위한 컴포넌트입니다. 폼이 아닌 일반 그룹 컨테이너에는 Card를 사용하세요.
접근성 (Accessibility)#
역할#
-
Flutter: 설정하는 시맨틱이 없습니다.
Fieldset은Opacity→SizedBox→Container→Column만 그리고Semantics를 하나도 두지 않아, "이 입력들이 한 그룹이다" 라는 사실이 보조기술에 전달되지 않습니다. -
Web: 루트는 native
<fieldset>이 아니라<div>이고role이 없습니다.legend는<div>래퍼 안의<span>으로 렌더되며<legend>도, 그룹과 잇는aria-labelledby도 없습니다.enabled: false일 때 루트에data-disabled빈 속성이 붙지만 이것은 aria 가 아니라 데이터 속성이라 보조기술이 읽지 않습니다. -
Web 루트는
attributes를 통과시키므로 호출부가Fieldset(attributes: {'role': 'group', 'aria-label': '배송지'})처럼 역할과 이름을 직접 실을 수 있습니다. Flutter 에는 대응하는 진입점이 없습니다.
키보드#
| 키 | 동작 |
|---|---|
| — |
이 컴포넌트가 가로채는 키는 없습니다.
Tab
/
Shift+Tab
은
children
에 놓인 입력들 사이를 선언 순서대로 지나갑니다.
|
Fieldset 자체는 포커스를 받지 않아 tab stop 이 아니며, 그룹을 한 번에 건너뛰는 키도 제공하지 않습니다.
스크린 리더#
-
접근 가능한 이름이 없습니다(양 플랫폼).
legend는 그룹의 제목이 아니라 그 앞에 놓인 별개의 텍스트로 읽히고, 안쪽 입력들은 서로 아무 관계가 없는 항목으로 나열됩니다. - 상태를 통보하는 라이브 리전이 없습니다.
enabled가 바뀌어도 아무것도 읽히지 않습니다. legend는 임의 위젯을 받는 슬롯이므로, 제목 수준 같은 텍스트 자체의 시맨틱은 호출부가 넣는 위젯이 결정합니다.
포커스 관리#
- 진입: 오버레이가 아니어서 열림/닫힘이 없고, 렌더될 때 포커스를 옮기지 않습니다.
- 이탈: 되돌아갈 트리거가 없습니다.
- 트랩: 없습니다. 마지막 자식에서
Tab을 누르면 그대로 바깥으로 나갑니다.
알려진 제약#
-
enabled: false는 시각 표시일 뿐 자식을 실제로 비활성화하지 않습니다(양 플랫폼). Flutter 는Opacity(0.5)로만 감싸고IgnorePointer/AbsorbPointer가 없으며, Web 은opacity-50클래스와data-disabled속성만 붙고pointer-events차단도aria-disabled도 없습니다(그data-disabled를 읽는 CSS 는 저장소 어디에도 없습니다). 그래서 비활성 Fieldset 안의 입력에도 여전히 Tab 으로 도달하고 값을 바꿀 수 있습니다. 비활성 상태를 실제로 적용하려면 각 자식에enabled: false를 직접 넘겨야 합니다. -
legend 가 그룹 이름으로 연결되지 않습니다. Web 은 native
<fieldset>/<legend>가 아니라<div>/<span>으로 렌더되며 루트에role="group"도aria-labelledby도 없고, Flutter 쪽 위젯에는Semantics가 하나도 없습니다. 스크린 리더는 legend 를 그룹 제목이 아니라 앞에 놓인 별개의 텍스트로 읽습니다. Web 은attributespassthrough 가 있어 호출부가role과aria-label을 직접 실을 수 있지만(legend<span>에는 id 를 줄 수 없어aria-labelledby대신 문자열을 중복 지정해야 합니다), Flutter 에는 대응하는 진입점이 없습니다. - OS 의 색 강제(forced-colors) 모드에서는 비활성 상태가 보이지 않습니다. 비활성 표현이 양 플랫폼 모두 투명도 하나뿐인데 그 모드는 색만 강제하고 알파는 그대로 두기 때문에, 비활성 Fieldset 이 활성과 똑같이 보입니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 비활성 전달 | FormFieldScope 의 메타데이터 | 같은 스코프의 InheritedComponent |
| 루트 | OutlinedContainer 안 Column | native fieldset이 아닌 <div> |
| legend | 텍스트 스타일·Padding 래퍼 | legend 텍스트 DOM 래퍼 |
| 비활성 표시 | Opacity | resolver의 opacity 및 data-disabled |
native <fieldset disabled> 를 직접 쓰는 것이 아니므로 모든 임의 자식을 차단한다고 기대하지 마세요. 스코프를 읽는 입력이 비활성을 따릅니다.