Menu
정적 메뉴 패널 컴포넌트
목차
Menu 는 테두리가 있는 패널 안에 풍부한 메뉴 항목 목록을 표시하는
정적(인라인) 메뉴 컴포넌트입니다. 항목은 CoreMenuItem 의 이름 있는
팩토리 생성자로 만듭니다 — 헤딩 라벨, 액션, 체크박스, 라디오, 구분선,
간격, 그리고 인라인 중첩 서브메뉴까지 지원합니다.
기본 사용법 (Basic Usage)#
Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
)
Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
)
Live Preview#
코드 보기
class MenuDefaultExample extends StatelessComponent {
const MenuDefaultExample({super.key});
@override
Component build(BuildContext context) {
return Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
);
}
}
class MenuDefaultExample extends StatelessWidget {
const MenuDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 액션이나 네비게이션 항목 목록을 패널로 직접 표시할 때
- leading/trailing 아이콘, 체크박스, 라디오를 포함한 메뉴가 필요할 때
- 인라인으로 펼쳐지는 계층적 서브메뉴 구조가 필요할 때
대신 다른 컴포넌트를 사용하세요:
- NavigationMenu: 명령 액션 행 대신 사이트 링크와 풍부한 드롭다운 콘텐츠가 필요할 때
- Tree: 명령 목록 대신 데이터 계층을 탐색할 때
- Popup: 항목 의미·탐색 없이 플로팅 패널 표면만 조립할 때
- DropdownMenu: 인라인 패널 대신 트리거 앵커의 오버레이 메뉴일 때
- ContextMenu: 포인터 위치에서 여는 컨텍스트 메뉴일 때
- Menubar: 가로 바에서 여러 메뉴 트리거를 전환해야 할 때
- Select: 액션 실행 대신 폼 값을 선택할 때
Props / Parameters#
W 는 Flutter의 Widget / Web의 Jaspr Component입니다. CoreMenuItem의 leading / trailing 같은 콘텐츠 슬롯도 같은 플랫폼 타입입니다.
Menu#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
items |
List<CoreMenuItem<W>> |
필수 | 패널에 렌더링할 메뉴 항목 |
vertical |
bool |
true |
false 면 항목을 가로로 배치 |
size |
CoreMenuItemSize |
CoreMenuItemSize.defaultSize(md, 36px) |
행 밀도 —
sm
32 /
md
36 /
lg
48. 메뉴가 소유하는 값이라 모든 대화형 행(
MenuItem
)에 같은 값을 넘깁니다. 한 패널 안에서 높이가 섞이지 않습니다
|
ariaLabel |
String? |
null |
메뉴 컨테이너의 접근성 이름 |
menuStyle |
CoreMenuStyle? |
null |
인스턴스별 chrome 스타일 |
onActivate |
void Function()? |
null |
서브메뉴 없는 action의 onPressed보다 먼저 호출하는 호스트 닫기 요청 |
CoreMenuItem 팩토리#
| 팩토리 | 설명 |
|---|---|
CoreMenuItem.label(text, {leading, trailing}) | 비대화 섹션 헤딩 |
CoreMenuItem.action(text, {description, tone, leading, trailing, shortcut, onPressed, href, submenu, active, disabled})
|
대화형 액션 항목 (submenu 지정 시 인라인 서브메뉴 트리거) |
CoreMenuItem.checkbox(text, {description, tone, trailing, onChanged, checked, disabled})
|
체크박스 토글 항목 |
CoreMenuItem.radio(text, {description, tone, trailing, onChanged, selected, disabled}) |
라디오 단일 선택 항목 |
CoreMenuItem.divider() | 시각적 구분선 |
CoreMenuItem.gap(size) | 고정 크기 간격 |
action / checkbox / radio 세 팩토리가 공유하는 행 속성:
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
description |
String? |
null |
라벨 아래 두 번째 줄. 있으면 그 행만 size 의 최소 높이를 넘어 자라고 두 줄이 위쪽 정렬됩니다 |
tone |
CoreMenuItemTone |
CoreMenuItemTone.defaultTone(neutral) |
critical
이면 라벨과 글리프가
error
색으로 읽히고, hover / focus / pressed 에서는 워시 대신
errorContainer
채움에
onErrorContainer
글자로 바뀝니다. 색이 유일한 단서라 파괴적 항목은 그룹 마지막, 보통 구분선 뒤에 둡니다
|
행은 MenuItem 이 그립니다#
action / checkbox / radio 항목은 Menu 가 직접 그리지 않고 MenuItem
을 합성합니다 — label 행·구분선·간격만 Menu 자신의 것입니다. 팩토리
필드는 다음처럼 행 파라미터로 옮겨집니다.
CoreMenuItem | MenuItem |
|---|---|
label
/
description
/
tone
/
shortcut
/
leading
/
trailing
|
같은 이름의 슬롯 (Text 로 감싸서) |
checked / selected (토글) · active (action) |
selected + mark: Icon(LucideIcons.check) (라디오는 점) |
| 패널에 토글 행이 하나라도 있음 | 모든 행에 reserveMark: true — 라벨 정렬 유지 |
submenu 비어있지 않음 |
hasSubmenu: true (셰브론, shortcut 은 숨김) |
disabled | enabled: false — 포커스는 받되 활성화만 무시 |
Menu.size | size |
CoreMenuStyle.itemStyle (→ effectiveItemStyle) |
menuItemStyle |
폭#
패널 폭은 CoreMenuStyle.defaultPopupStyle 이 minWidth 240 / maxWidth
280 (CoreLayout.layout240 / layout280) 으로 진술하고 합성된
Popup 이
그립니다 — 내용이 그 사이에서 폭을 정하고, 위젯은 폭을 따로 박지 않습니다.
다른 폭이 필요하면 menuStyle: CoreMenuStyle(popupStyle: CorePopupStyle(minWidth: …, maxWidth: …))
로 덮습니다.
스타일 시스템 (Style System)#
Menu 의 모든 chrome / dimensional 오버라이드는 단일 CoreMenuStyle
슬롯으로 흐릅니다.
시맨틱 vs 스타일#
-
시맨틱 / behaviour: 위젯 파라미터로 직접 (
vertical,size,ariaLabel,onActivate) -
chrome / dimensional:
CoreMenuStyle한 곳으로 — 패널 박스는popupStyle: CorePopupStyle?, 대화형 행은itemStyle: CoreMenuItemStyle?, 헤딩 행은labelTextStyle(labelSmall역할 그대로, 12 Medium) ·labelBottomInset(헤딩 아래 4 — 위와 좌우는 행의 안쪽 여백을 따름), 구분선은dividerStyle·dividerInset(세로 메뉴에서 구분선 위아래 4), 인라인 서브메뉴는submenuStyle/submenuIndent/leadingColumnWidth
Resolve chain#
CoreMenuStyle.defaultX (디자인 시스템 기본값)
→ CoreMenuTheme.style // 프로젝트 공통
→ widget.menuStyle // 인스턴스별
행 chrome 은 한 단계 더 흐릅니다 — Menu resolver 가
CoreMenuStyle.effectiveItemStyle 을 각 MenuItem 에 raw-forward 하고,
행의 resolver 가 size / tone 사다리와 나머지 기본값을 채웁니다.
CoreMenuStyle.defaultItemStyle
→ CoreMenuStyle.itemStyle // Menu 의 슬롯
→ MenuItem(menuItemStyle: …) // 행 resolver — defaultsBySize / defaultsByTone 위에 overlay
행 chrome — 여백, 하이라이트 반경, 아이콘 열 ↔ 라벨 사이 간격, 단축키 힌트,
마크 / 셰브런 글리프 — 은 전부 itemStyle: CoreMenuItemStyle? 한 슬롯으로
들어갑니다.
defaultItemStyle 이 바꾸는 것은 반경 하나입니다 — 패널 안의 행은 사이즈와
무관하게 6 입니다. 패널 모서리 12 에서 패널 패딩 6 을 뺀 파생값이라 어느 사이즈에서도
동심입니다. 혼자 선 MenuItem 의 반경은 행 높이 × 0.25 이지만, 그대로 따르면
패널 안의 lg 행(12)이 바깥 모서리 12 와 같아져 패딩 6 만큼 안쪽에서 바깥보다
둥글어집니다. 세로 패널 · 가로 바 · 인라인 서브메뉴, 그리고 Menu 를 합성하는
DropdownMenu · ContextMenu · Menubar
패널이 모두 이 값을 받습니다.
CoreMenuStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
popupStyle |
CorePopupStyle? |
Nested panel-box chrome style — forwarded straight to the composed
Popup(popupStyle: …)
, which resolves the box background / border / radius / padding / shadow / min-width and the inter-row spacing. Only the menu-divergent fields are pre-set in [defaultPopupStyle]; the rest fall back to [CorePopupStyle]'s own defaults inside the popup resolver (raw-forward). Overlaid by the per-instance value.
|
submenuStyle |
CoreMenuStyle? |
Nested style for an inline nested submenu — the submenu is itself a
Menu
(an indented child), so this slot carries a full [CoreMenuStyle] that the resolver raw-forwards to the child's
menuStyle
(the child self-resolves, exactly like
dividerStyle
forwards to
Separator
/
chevronIconStyle
to
Icon
). Defaults to [defaultSubmenuStyle] (a flush, box-less panel) so inline submenus read as an indented group; a designer can override this slot to give nested submenus their own box / item chrome. Overlaid by the per-instance value on top of the default.
|
itemStyle |
CoreMenuItemStyle? |
Nested chrome of every interactive row — the
MenuItem
the menu composes.
null
→ [defaultItemStyle]; resolvers raw-forward [effectiveItemStyle] to each row (the
MenuItem
resolver fills the size / tone ladders). Size and tone themselves are not here — the menu passes
size
as a prop and each entry carries its own tone.
|
labelTextStyle |
CoreTextStyle? |
Nested text style for non-interactive label / header entries (
CoreMenuItemKind.label
) — overlaid on the [defaultLabelTextStyle] (
labelSmall
role + variant colour) and forwarded to the entry's
Text
/
<span>
as a paint-ready style. Carries the heading weight / colour so both platforms drive the caption typography from this single slot.
|
dividerStyle |
CoreDividerStyle? |
Nested divider style for the divider entry — forwarded straight to
Divider(dividerStyle: …)
, which resolves the colour / thickness (rule 2.2 child-component nested slot). Overlaid on [defaultDividerStyle].
|
labelBottomInset |
double? |
Space left below a label / heading entry (logical px). The heading's top and sides follow the row inset (the forwarded row chrome, else the size ladder) so its text lines up with the rows; only the bottom is its own.
null
→ [defaultLabelBottomInset].
|
dividerInset |
double? |
Space kept on each side of a divider entry across the menu's cross axis (logical px) — above and below the rule in a column menu, either side of it in a bar.
null
→ [defaultDividerInset].
|
leadingColumnWidth |
double? |
Leading-icon column width (logical px). |
submenuIndent |
double? |
Per-level submenu indentation (logical px). |
사용 예#
Menu(
size: CoreMenuItemSize.sm,
items: items,
menuStyle: const CoreMenuStyle(
popupStyle: CorePopupStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
),
itemStyle: CoreMenuItemStyle(
selectedBackgroundColor: CoreColor.token(CoreColors.secondary),
selectedForegroundColor: CoreColor.token(CoreColors.onSecondary),
),
),
)
패널 박스 chrome 은 popupStyle 이, 행 chrome 은 itemStyle 이 받습니다 —
Menu 자신은 그 값을 다시 해석하지 않고 Popup 과 MenuItem
에 그대로
넘깁니다(합성 컴포넌트가 자기 것을 풉니다).
위젯 슬롯 — leading / trailing 은 위젯 그대로#
CoreMenuItem 의 leading / trailing 은 W? 슬롯이라 아이콘·인디케이터
·Text chain 을 그대로 받습니다(shadcn asChild 패턴). 슬롯 안 위젯의
시맨틱은 호출자가 정하고, 행은 위치만 잡습니다. CoreMenuStyle.itemStyle.leadingIconStyle
같은 nested 슬롯은 그 자리에 놓인 Icon 의 chrome 미세 조정용입니다 —
어떤 위젯을 놓을지는 슬롯이 정합니다.
CoreMenuItem.action(
'Sync',
leading: const Icon(LucideIcons.refreshCw),
trailing: Text('Beta').labelSmall.onSurfaceVariant,
onPressed: () {},
)
빠른 오버라이드 (Chain)#
이미 만든 Menu 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class MenuChainExample extends StatelessWidget {
const MenuChainExample({super.key});
@override
Widget build(BuildContext context) {
return Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
).withStyle(
const CoreMenuStyle(
itemStyle: CoreMenuItemStyle(
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
contentGapStyle: CoreGapStyle(size: CoreSpace.space12),
markIconStyle: CoreIconStyle(
color: CoreColor.token(CoreColors.tertiary),
),
),
dividerStyle: CoreDividerStyle(
color: CoreColor.token(CoreColors.primary),
thickness: CoreStrokeWidth.stroke2,
),
),
);
}
}
class MenuChainExample extends StatelessComponent {
const MenuChainExample({super.key});
@override
Component build(BuildContext context) {
return Menu(
ariaLabel: 'Main menu',
items: [
const CoreMenuItem.label('Navigation'),
CoreMenuItem.action('Home', onPressed: () {}, active: true),
CoreMenuItem.action('Profile', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.checkbox('Show grid', onChanged: (_) {}, checked: true),
CoreMenuItem.action('Settings', disabled: true),
],
).withStyle(
const CoreMenuStyle(
itemStyle: CoreMenuItemStyle(
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space20,
vertical: CoreSpace.space12,
),
contentGapStyle: CoreGapStyle(size: CoreSpace.space12),
markIconStyle: CoreIconStyle(
color: CoreColor.token(CoreColors.tertiary),
),
),
dividerStyle: CoreDividerStyle(
color: CoreColor.token(CoreColors.primary),
thickness: CoreStrokeWidth.stroke2,
),
),
);
}
}
변형 (Variants)#
서브메뉴 (인라인 중첩)#
Menu(
items: [
CoreMenuItem.action('File', onPressed: () {}),
CoreMenuItem.action(
'Export',
submenu: [
CoreMenuItem.action('PDF', onPressed: () {}),
CoreMenuItem.action('CSV', onPressed: () {}),
],
),
],
)
서브메뉴는 부모 항목 아래에 한 단계 들여쓰기되어 인라인으로 펼쳐집니다.
가로 메뉴#
Menu(
vertical: false,
items: [
CoreMenuItem.action('Home', onPressed: () {}),
CoreMenuItem.action('Docs', onPressed: () {}),
],
)
체크 / 라디오 항목#
Menu(
items: [
CoreMenuItem.checkbox('Auto save', onChanged: (_) {}, checked: true),
CoreMenuItem.radio('Compact', onChanged: (_) {}, selected: true),
CoreMenuItem.radio('Comfortable', onChanged: (_) {}),
],
)
동작 스펙 (Behavior)#
항목 활성화#
-
action: 서브메뉴가 없으면
onActivate(호스트 닫기 요청) →onPressed순서. 서브메뉴 부모는 호스트 닫기를 요청하지 않습니다.disabled면 무시 — 행은disabledForegroundColor로 그려지되 포커스는 받습니다 -
checkbox: 활성화 시
onChanged(!checked)호출, 체크 시 mark 칸에 체크 글리프 +selected채움 -
radio: 활성화 시
onChanged(true)호출, 선택 시 mark 칸에 점 글리프selected채움
-
패널에 토글 행이 하나라도 있으면 모든 행이 mark 칸 폭을 예약합니다
(
reserveMark) — 일부 행만 켜져도 라벨이 어긋나지 않습니다 - checkbox / radio 값은 호출부가 갱신해야 선택 표시가 바뀝니다. href는 data-href 정보이며 Menu가 자동 이동하지 않습니다. Menu 자체는 표시 패널이며 오버레이 열기·닫기는 호스트가 담당합니다.
Hover / Focus / Pressed#
-
행이 스스로 상태 레이어를 그립니다 — 전경색을
itemStyle.stateLayer(hover 8% / focus 8% / pressed 12%) 알파로 워시합니다. 자세한 규칙은 MenuItem 의 동작 스펙.
밀도 (size)#
-
size는 메뉴 한 곳에서 정하고 모든 행에 같은 값이 갑니다 — sm 32 / md 36 / lg 48.description이 있는 행만 그 최소 높이를 넘어 자랍니다.
긴 목록#
-
Flutter: 세로
Menu는 받은 높이가 행에 모자라면 행을 패널 안에서 스크롤합니다. 높이를 제한하는 쪽은 호스트입니다 —DropdownMenu·ContextMenu·Menubar의 패널이 뷰포트가 남기는 높이로 제한됩니다. 제한이 없는 곳에서는 행이 그대로 펼쳐지고, 가로Menu는 스크롤하지 않습니다. -
Web:
Menu는 파라미터를 더하지 않고, 호스트가 위치 엔진이 정한 높이를 내부 범위로 알려 주면 그 높이를 따릅니다. 그러면Menu가 패널 상자에max-height를 걸고 행을 상자 안의 목록에 담아 그 목록만overflow-y: auto로 스크롤합니다. 상자는 스크롤하지 않으므로 테두리·모서리·그림자와, 상자 안에 그려지는 글래스 림이 행과 함께 밀려나지 않고 제자리에 있습니다. 높이를 알리지 않는 곳에서는 행이 상자의 직접 자식으로 그대로 펼쳐집니다.
서브메뉴#
-
CoreMenuItem.action에submenu를 지정하면 shortcut 자리에 chevron 아이콘이 그려지고(shortcut은 숨김), 항목 아래에 중첩Menu가 인라인으로 렌더됩니다. - 인라인 서브메뉴는 부모 패널의 스크롤에 함께 실립니다. 자기 스크롤러를 따로 만들지 않습니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
독립적으로 쓸 때는 ariaLabel 지정
Menu(
ariaLabel: 'Main menu',
items: [
CoreMenuItem.action('Home', onPressed: () {}),
CoreMenuItem.action('Profile', onPressed: () {}),
],
)
Web 은 role="menu" 만으로는 이름 붙은 조상 트리거가 없으면 스크린 리더가 메뉴의 목적을 알 수 없습니다. ariaLabel 은 Flutter Semantics 컨테이너 라벨로도 동일하게 노출됩니다.
❌ Don't#
커스텀 오버레이 안에서 onActivate 연결을 빠뜨리지 않기
// ❌ onActivate 미연결 — 액션 클릭 후에도 패널이 열린 채로 남음
Popover(
content: Menu(items: actionItems),
)
// ✅ onActivate 로 닫기 로직 연결
Popover(
content: Menu(
items: actionItems,
onActivate: closePopover,
),
)
Menu 자신은 열림/닫힘 상태를 관리하지 않습니다 — action 항목이 활성화되면 onActivate 가 호출될 뿐이므로, 직접 만든 오버레이 안에 Menu 를 넣었다면 그 콜백으로 닫기 로직을 연결해야 합니다(DropdownMenu/ContextMenu/Menubar 는 이미 이 배선을 대신 해줍니다).
접근성 (Accessibility)#
Menu 는 항목 목록을 그립니다. 열고 닫기·포커스·Escape 는 이것을 합성하는
호스트(DropdownMenu·ContextMenu·Menubar·NavigationMenu)와 그 오버레이가
소유하므로, 아래는 두 층을 나눠 적습니다.
역할#
- Flutter — 루트 CoUISemantics가 vertical이면 menu, 가로이면 menuBar 역할과 ariaLabel을 노출합니다. MenuRowScope 안의 MenuItem은 menuItem 역할·enabled·selected를 사용하며 이름은 행 콘텐츠에서 옵니다.
-
Web — 컨테이너는
role="menu"(세로) /role="menubar"(가로) +aria-orientation. 행 role 은 항목 종류에서 파생됩니다 — actionmenuitem, checkboxmenuitemcheckbox+aria-checked, radiomenuitemradio+aria-checked.Menu가attributes로MenuItem에 통과시키고(MenuItem단독 기본값은menuitem), 서브메뉴 트리거는aria-haspopup="menu", 비활성 행은aria-disabled="true", active 행은aria-current를 답니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Enter / Space | 포커스된 항목 활성화 | 포커스된 항목 활성화 |
Tab |
항목마다 개별 tab stop | 메뉴 전체가 한 tab stop (roving tabindex — 활성 행 0, 나머지 -1) |
ArrowUp / ArrowDown |
프레임워크 방향성 순회 (메뉴 의미론 없음) | 이전 / 다음 행, 끝에서 순환 |
Home / End | 없음 | 첫 / 마지막 행 |
| 문자 키 | 없음 | 그 글자로 시작하는 다음 행으로 점프 (typeahead) |
Web 의 화살표·Home/End·typeahead 는 비활성 행도 건너뛰지 않습니다 —
WAI-ARIA APG 대로 enabled: false 행은 포커스를 받되 활성화만 무시합니다
(inert 지 invisible 이 아닙니다). Flutter 는 아직 메뉴 탐색을 구현하지
않았습니다 — 화살표가 포커스를 옮기는 것은 프레임워크의 방향성 순회이지
메뉴 탐색이 아닙니다(순환·Home/End·typeahead 없음).
Escape 는 Menu 자신이 아니라 호스트가 제공합니다:
| 호스트 | Flutter | Web |
|---|---|---|
DropdownMenu · ContextMenu · NavigationMenu |
닫힘 | 닫힘 |
Menubar | 없음 | 닫힘 |
Flutter Menubar 는 non-modal 로 열려 오버레이의 Escape 배선을 받지 않습니다.
브라우저 검증 — 이 컴포넌트의 Web 키보드 이동은 야간 파리티 스윕의 키보드 프로브(
tool/parity/keyboard_probe.dart)가 하이드레이션된 문서 사이트에서 실제로 키를 눌러, 포커스가 기대한 항목으로 옮겨 갔는지 확인합니다. 핸들러가 달렸는지가 아니라 포커스가 어디에 있는지를 봅니다.
스크린 리더#
-
Flutter — 접근 가능한 이름의 출처는
ariaLabel하나입니다(Semantics컨테이너 라벨). 항목의 역할·체크 상태는 전달되지 않습니다. -
Web — 항목 상태를
aria-checked(checkbox/radio 항목) ·aria-current(현재 항목) ·aria-disabled로 알리고, 방향은aria-orientation으로 전달합니다.
항목 수와 위치는 통보되지 않습니다 — 리더가 "N개 중 3번째" 를 읽으려면
aria-setsize/aria-posinset 이 필요한데 양쪽 다 없습니다. 메뉴가 열리고 닫힌
사실도 live region 으로 통보되지 않고, 트리거의 확장 상태로만 노출됩니다.
포커스 관리#
-
진입 —
Menu자신은 포커스를 옮기지 않습니다. Flutter 의 modal 호스트 (DropdownMenu·ContextMenu·NavigationMenu)는 오버레이가 열릴 때 패널 스코프로 포커스를 옮깁니다(첫 항목이 아니라 패널입니다). -
트랩 — Flutter modal 호스트에서는
Tab이 패널 안에 갇히고, 배경이 스크린 리더에서 가려집니다. Web 은 갇히지 않아 메뉴(한 tab stop) 다음Tab이 패널 밖으로 나갑니다. - 이탈 — 닫을 때 트리거로 포커스를 되돌리는 코드가 양 플랫폼 어디에도 없습니다.
알려진 제약#
- Flutter 는 항목 수만큼 tab stop 이 생깁니다 (Web 은 하나).
- 메뉴가 닫힌 뒤 포커스가 어디로 가는지 보장되지 않습니다.
- 포커스 트랩과 배경 격리가 Flutter modal 호스트에만 있어, 같은 메뉴라도 플랫폼과 호스트에 따라 키보드 경험이 다릅니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 패널 | menu / menuBar 시맨틱 안 Popup | role=menu / menubar의 flex 패널 |
| 항목 | CoreMenuItem 팩토리 (양쪽 동일 API) |
CoreMenuItem 팩토리 (양쪽 동일 API) |
| 구분선 | 의미 있는 Divider | 의미 있는 Divider (role=separator) |
| 서브메뉴 | 인라인 중첩 Menu | 인라인 중첩 Menu |
| 아이콘 | Icon | Icon |
| ARIA | Flutter Semantics |
role / aria-* 속성 |
| 항목 탐색 | 항목별 Tab · Enter / Space | roving · 화살표 · Home / End · typeahead |
| 긴 목록 | 세로 Menu 가 받은 높이 안에서 스크롤 |
호스트가 알린 높이를 상자의 max-height 로 걸고, 상자 안의 목록이 overflow-y: auto |
관련 컴포넌트 (Related Components)#
- DropdownMenu: 트리거로 떠오르는 오버레이 메뉴
- ContextMenu: 우클릭/롱프레스 컨텍스트 메뉴
- Menubar: 가로 애플리케이션 메뉴바
- Popover: 임의의 콘텐츠를 표시하는 팝업