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

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#

사용 시기 (When to Use)#

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

  • 긴 폼을 주제별로 그룹화하여 시각적으로 구분해야 할 때 (기본 정보, 결제 정보, 배송 정보 등)
  • 관련된 폼 필드들에 공통 제목(legend)이 필요할 때
  • 특정 조건에서 관련 필드 그룹 전체를 비활성화해야 할 때

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

  • Form: 필드 그룹 표시 대신 폼 전체의 검증·컨트롤러 스코프가 필요할 때
  • TextField: 그룹 없이 단일 문자열 입력만 필요할 때
  • Card: 폼의 disabled 스코프 없이 일반 콘텐츠 표면이 필요할 때

Props / Parameters#

W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. children은 같은 플랫폼 콘텐츠 목록입니다.

속성타입기본값설명
legendW?null상단 제목
children List<W> [] 그룹화할 자식 요소
enabled bool true 그룹 비활성 스코프 제공
size CoreComponentSize md 간격·제목 크기 단계
fieldsetStyle CoreFieldsetStyle? null chrome 단일 진입점

CoreFieldsetStyle 필드#

필드타입설명
paddingCoreEdgeInsets?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 은 attributes passthrough 가 있어 호출부가 role 과 aria-label 을 직접 실을 수 있지만(legend <span> 에는 id 를 줄 수 없어 aria-labelledby 대신 문자열을 중복 지정해야 합니다), Flutter 에는 대응하는 진입점이 없습니다.
  • OS 의 색 강제(forced-colors) 모드에서는 비활성 상태가 보이지 않습니다. 비활성 표현이 양 플랫폼 모두 투명도 하나뿐인데 그 모드는 색만 강제하고 알파는 그대로 두기 때문에, 비활성 Fieldset 이 활성과 똑같이 보입니다.

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

항목FlutterWeb
비활성 전달FormFieldScope 의 메타데이터같은 스코프의 InheritedComponent
루트OutlinedContainer 안 Columnnative fieldset이 아닌 <div>
legend텍스트 스타일·Padding 래퍼legend 텍스트 DOM 래퍼
비활성 표시Opacityresolver의 opacity 및 data-disabled

native <fieldset disabled> 를 직접 쓰는 것이 아니므로 모든 임의 자식을 차단한다고 기대하지 마세요. 스코프를 읽는 입력이 비활성을 따릅니다.

  • Form: 폼 루트 컨테이너
  • FormField: 개별 필드 wrapping (label + input + error)
  • Card: 일반 콘텐츠 그룹 컨테이너