SubfocusScope | CoUI
LogoCoUI

SubfocusScope

자식 Subfocus 항목 간 방향키 포커스 이동을 관리하는 스코프 (인프라)

SubfocusScope#

내부 Subfocus 항목들 사이의 방향키 포커스 이동을 관리하는 스코프(인프라)입니다. orientation 이 traversal 축(↑↓ vs ←→)을 결정하고, 각 Subfocus(focusId:) 항목이 스코프에 자신을 등록합니다. 메뉴·리스트·툴바 같은 컴포넌트의 키보드 내비게이션 기반입니다.

Live Preview#

사용법#

SubfocusScope(
  child: Column(
    children: [
      Subfocus(focusId: 'a', child: Card(child: Text('항목 A').bodyMedium)),
      Subfocus(focusId: 'b', child: Card(child: Text('항목 B').bodyMedium)),
      Subfocus(focusId: 'c', child: Card(child: Text('항목 C').bodyMedium)),
    ],
  ),
)

빠른 오버라이드 (Chain)#

이미 만든 Subfocus 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

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

  @override
  Widget build(BuildContext context) {
    return SubfocusScope(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        spacing: CoreSpace.space8,
        children: [
          Subfocus(
            focusId: 'a',
            child: Card(child: Text('항목 A (↑↓ 키로 이동)').bodyMedium),
          ).withStyle(
            const CoreSubfocusStyle(
              focusRingRadius: CoreBorderRadius.all(CoreRadius.radius16),
            ),
          ),
          Subfocus(
            focusId: 'b',
            child: Card(child: Text('항목 B').bodyMedium),
          ),
          Subfocus(
            focusId: 'c',
            child: Card(child: Text('항목 C').bodyMedium),
          ),
        ],
      ),
    );
  }
}
class SubfocusChainExample extends StatelessComponent {
  const SubfocusChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return SubfocusScope(
      child: div(
        classes: 'flex flex-col items-center gap-${CoreSpace.scale.space8}',
        [
          Subfocus(
            focusId: 'a',
            child: Card(child: Text('항목 A (↑↓ 키로 이동)').bodyMedium),
          ).withStyle(
            const CoreSubfocusStyle(
              focusRingRadius: CoreBorderRadius.all(CoreRadius.radius16),
            ),
          ),
          Subfocus(
            focusId: 'b',
            child: Card(child: Text('항목 B').bodyMedium),
          ),
          Subfocus(
            focusId: 'c',
            child: Card(child: Text('항목 C').bodyMedium),
          ),
        ],
      ),
    );
  }
}

Props#

SubfocusScope#

파라미터타입기본값설명
child Widget / Component required 스코프 내용 (Subfocus 항목 포함)
autofocus bool false 마운트 시 첫 항목 자동 포커스
orientation CoreSubfocusOrientation vertical traversal 축 (vertical ↑↓ / horizontal ←→)

Subfocus (항목)#

파라미터타입기본값설명
child Widget / Component required 포커스 가능한 항목 콘텐츠
focusIdStringrequired스코프 내 고유 식별자
enabled bool true traversal 참여 여부
subfocusStyle CoreSubfocusStyle? null 항목 chrome 단일 진입점

스타일 시스템 (Style System)#

CoreSubfocusStyle 필드#

필드타입설명
focusRingRadius CoreBorderRadius? Corner radius of the focus ring drawn around a focused item.

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

✅ Do#

활성화 처리는 child 자체가 담당하게 하기

Subfocus(
  focusId: 'save',
  child: Button(onPressed: handleSave, child: Text('Save')),
)

SubfocusScope/Subfocus는 방향키로 subfocus를 옮기는 역할만 합니다 — Enter/Space 로 항목을 "실행"하는 처리는 하지 않으므로, 실제 활성화는 child(Button 등)가 자기 몫으로 구현해야 합니다.


❌ Don't#

enabled: false가 Flutter에서도 비활성으로 안내될 거라 가정하지 않기

// ❌ Flutter에서는 비활성 상태가 스크린 리더에 전달되지 않음
Subfocus(
  focusId: 'archived-item',
  enabled: false,
  child: Card(child: Text('Archived').bodyMedium),
)

Web은 aria-disabled="true"를 붙이지만, Flutter의 Subfocus는 시맨틱을 전혀 만들지 않습니다 — Flutter에서 비활성 의미까지 전달하려면 child 자체에 시맨틱을 추가하세요.

접근성 (Accessibility)#

역할 / 시맨틱#

두 플랫폼이 비대칭입니다.

  • Web: SubfocusScope 루트가 role="group", 각 Subfocus 항목이 role="option" 을 내보냅니다. enabled: false 인 항목에는 aria-disabled="true" 가 붙고, traversal 용 data-co-subfocus-id / data-co-subfocus-enabled 훅이 함께 실립니다.
  • Flutter: SubfocusScopeSemantics(container: true) 만 내보냅니다 — 시맨틱 경계일 뿐 역할이 아닙니다. Subfocus 항목은 Semantics 를 전혀 만들지 않습니다(MouseRegion > GestureDetector > FocusOutline 구성).

즉 Web 항목만 역할을 가지고, Flutter 항목은 역할도 상태도 내보내지 않습니다.

키보드#

방향키 4개만 처리합니다. 어느 키가 살아 있는지는 orientation 이 결정합니다.

동작
ArrowUp / ArrowDown 이전 / 다음 항목으로 subfocus 이동 (vertical · both 에서만)
ArrowLeft / ArrowRight 이전 / 다음 항목으로 subfocus 이동 (horizontal · both 에서만)

이동 대상은 요청한 축에서 현재 항목보다 앞에 있는 후보 중 공간적으로 가장 가까운 항목이며, 이 규칙은 양 플랫폼 동일합니다. Web 은 처리한 키에 preventDefault() 를 호출해 페이지 스크롤을 막습니다.

Enter · Space · Home · End · 타입어헤드는 처리하지 않습니다 — 항목의 활성화는 Subfocus 가 아니라 자식(Button 등)이 책임집니다.

포커스#

포커스 가능하지만 메커니즘이 플랫폼마다 다릅니다.

  • Flutter: 실제 프레임워크 포커스는 스코프 하나만 가집니다. 스코프가 FocusableActionDetector 안에서 FocusNode 를 소유하고(autofocus 지원), 항목은 FocusNode 를 갖지 않습니다. 항목을 탭하면 포커스는 스코프로 갑니다.
  • Web: 스코프가 tabindex="0"(요청 시 autofocus)이고, 각 항목도 독립적으로 tabindex 를 가집니다 — enabled"0", 아니면 "-1". 그래서 방향키 이동이 실제 DOM 포커스를 항목 위로 옮깁니다.

포커스 링은 양쪽 다 FocusOutline 로 디자인 시스템 링을 그리고, 포인터 조작에는 링을 일부러 띄우지 않습니다 — Flutter 는 키보드 traversal 에서만 링 플래그를 켜고 탭에서는 끄며, Web 은 :focus-visible 로 판정하고 루트에 outline-none 을 실어 브라우저 기본 링을 대체합니다. 키보드 이동 시 대상 항목은 화면 안으로 스크롤됩니다(Flutter Scrollable.ensureVisible, Web 은 네이티브 포커스 스크롤).

포커스 트랩과 포커스 복원은 양쪽 모두 없습니다.

스크린 리더#

  • Web: 스코프는 그룹으로, 각 항목은 옵션으로 안내되고 비활성 항목은 aria-disabled 로 비활성 상태까지 전달됩니다. 다만 aria-activedescendant · aria-selected · 접근 가능한 이름이 모두 없어, 항목은 오직 자식 콘텐츠로만 읽힙니다.
  • Flutter: 스코프는 역할도 레이블도 없는 시맨틱 컨테이너이고 항목은 시맨틱을 내보내지 않습니다. 항목 콘텐츠가 평범한 콘텐츠로 읽힐 뿐 옵션 · 선택 · 비활성 상태가 전혀 안내되지 않습니다 — Flutter 에서는 비활성 항목이 비활성으로 안내되지 않습니다.

알려진 제약#

  • Web 의 role="option"role="group" 바로 안에 있습니다. ARIA 상 optionlistbox 조상을 요구하므로 이 옵션들은 구조적으로 고아 상태이고, 리더가 "N개 중 M번째" 나 선택 상태를 노출하지 않을 수 있습니다. 코드 어디에서도 listbox · aria-selected · aria-activedescendant 를 내보내지 않습니다. 리스트박스 시맨틱이 필요하다면 호출자가 바깥에서 구성해야 합니다.
  • 두 플랫폼이 파리티가 아닙니다. Flutter 항목은 포커스 대상도, 역할 보유자도, 비활성 안내 대상도 아닙니다. Flutter 에서 Tab 은 스코프까지만 도달하고 스크린 리더 사용자는 옵션 시맨틱을 전혀 받지 못합니다.
  • 끝에서 순환하지 않습니다. 요청한 방향으로 더 이상 후보가 없으면 그대로 멈춥니다 — 마지막 항목이 종점입니다.
  • 접근 가능한 이름이 없습니다. 스코프에도 항목에도 이름 파라미터가 없으므로, 이 목록이 무엇인지 알리려면 호출자가 이름을 붙여야 합니다.
  • Home / End / 타입어헤드 / 포커스 트랩 / 포커스 복원은 제공하지 않습니다.

포커스 링 자체의 대비 · 강제 색상 동작은 전역 접근성 축 을 참고하세요.