SubfocusScope#
내부 Subfocus 항목들 사이의 방향키 포커스 이동을 관리하는 스코프(인프라)입니다. orientation
이 traversal 축(↑↓ vs ←→)을 결정하고, 각 Subfocus(focusId:) 항목이 스코프에 자신을 등록합니다. 메뉴·리스트·툴바 같은 컴포넌트의 키보드 내비게이션 기반입니다.
Live Preview#
class SubfocusScopeDefaultExample extends StatelessComponent {
const SubfocusScopeDefaultExample({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),
),
Subfocus(focusId: 'b', child: Card(child: Text('항목 B').bodyMedium)),
Subfocus(focusId: 'c', child: Card(child: Text('항목 C').bodyMedium)),
],
),
);
}
}
class SubfocusScopeDefaultExample extends StatelessWidget {
const SubfocusScopeDefaultExample({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),
),
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),
),
],
),
);
}
}
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),
),
],
),
);
}
}
사용법#
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 | 포커스 가능한 항목 콘텐츠 |
focusId | String | required | 스코프 내 고유 식별자 |
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:
SubfocusScope는Semantics(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 상option은listbox조상을 요구하므로 이 옵션들은 구조적으로 고아 상태이고, 리더가 "N개 중 M번째" 나 선택 상태를 노출하지 않을 수 있습니다. 코드 어디에서도listbox·aria-selected·aria-activedescendant를 내보내지 않습니다. 리스트박스 시맨틱이 필요하다면 호출자가 바깥에서 구성해야 합니다. -
두 플랫폼이 파리티가 아닙니다. Flutter 항목은 포커스 대상도, 역할 보유자도,
비활성 안내 대상도 아닙니다. Flutter 에서
Tab은 스코프까지만 도달하고 스크린 리더 사용자는 옵션 시맨틱을 전혀 받지 못합니다. - 끝에서 순환하지 않습니다. 요청한 방향으로 더 이상 후보가 없으면 그대로 멈춥니다 — 마지막 항목이 종점입니다.
- 접근 가능한 이름이 없습니다. 스코프에도 항목에도 이름 파라미터가 없으므로, 이 목록이 무엇인지 알리려면 호출자가 이름을 붙여야 합니다.
Home/End/ 타입어헤드 / 포커스 트랩 / 포커스 복원은 제공하지 않습니다.
포커스 링 자체의 대비 · 강제 색상 동작은 전역 접근성 축 을 참고하세요.