ContextMenu
우클릭 또는 길게 누르기로 표시되는 컨텍스트 메뉴 컴포넌트
목차
우클릭(데스크탑), 길게 누르기(터치 · 펜), 컨텍스트 메뉴 키 / Shift+F10(키보드)로
표시되는 상황별 메뉴 컴포넌트입니다. 자식 영역 안에서 우클릭하면 액션 목록이 포인터
위치에 떠 있는 패널로 나타나고, 키보드로 열면 포커스를 가진 요소의 중심에 나타납니다.
ContextMenu는 레거시 Flutter ContextMenu와 Web ContextMenu를 통일한
컴포넌트입니다. Flutter와 Web이 동일한 파라미터 이름·동작·시각을 갖습니다.
액션 항목은 통합 CoreMenuItem 모델을 씁니다 — 컨텍스트 메뉴와
드롭다운 메뉴는 동일한 액션 / 구분선 엔트리 의미를 공유합니다.
기본 사용법 (Basic Usage)#
ContextMenu(
items: [
CoreMenuItem.action('Copy', onPressed: () {}),
CoreMenuItem.action('Paste', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Delete', onPressed: () {}),
],
child: const Text('Right-click here'),
)
ContextMenu(
items: [
CoreMenuItem.action('Copy', onPressed: () {}),
CoreMenuItem.action('Paste', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Delete', onPressed: () {}),
],
child: div([text('Right-click here')]),
)
글라이드 · 미리보기 · 햅틱 (Opt-in)#
세 동작은 모두 꺼진 채로 시작하므로 켜기 전의 화면은 그대로입니다. 켜면 길게 눌러 연 손가락을 떼지 않고 움직여 행을 고르고, 메뉴 옆에 대상의 미리보기가 뜨며, 행에 들어설 때 손끝에 틱이 옵니다.
ContextMenu(
glideToSelect: true,
haptics: true,
preview: const Text('Quarterly report.pdf'),
items: [
CoreMenuItem.action('Open', onPressed: () {}),
CoreMenuItem.action('Share', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Delete', onPressed: () {}),
],
child: const Text('Quarterly report.pdf'),
)
ContextMenu(
glideToSelect: true,
haptics: true,
preview: Text('Quarterly report.pdf'),
items: [
CoreMenuItem.action('Open', onPressed: () {}),
CoreMenuItem.action('Share', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Delete', onPressed: () {}),
],
child: Text('Quarterly report.pdf'),
)
들어 올리기 (Opt-in)#
liftChild: true 면 메뉴가 열려 있는 동안 child 가 페이지에서 들려 올라갑니다. 길게 누르면
메뉴가 나타나기 전에 child 가 먼저 올라와 누르기가 인식되고 있음을 보여 주고, 우클릭 ·
키보드 · controller 로 열면 메뉴가 열리는 순간 올라오며, 어떻게 닫든 메뉴가 child
안으로
접히면서 child 가 내려앉습니다. 꺼진 채로 시작하므로 켜기 전의 화면은 그대로입니다.
ContextMenu(
liftChild: true,
haptics: true,
items: [
CoreMenuItem.action('Open', onPressed: () {}),
CoreMenuItem.action('Share', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Delete', onPressed: () {}),
],
child: const Text('Press and hold'),
)
ContextMenu(
liftChild: true,
haptics: true,
items: [
CoreMenuItem.action('Open', onPressed: () {}),
CoreMenuItem.action('Share', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Delete', onPressed: () {}),
],
child: Text('Press and hold'),
)
child 는 열려 있는 동안 두 번 빌드됩니다. 들어 올려진 것은 같은 위젯(컴포넌트)을 한 번 더
빌드한 두 번째 그림이라 자기 상태가 없고(상태를 가진 하위 요소는 초기 상태에서 시작합니다),
child 가 무거우면 그만큼이 열려 있는 동안 두 번 듭니다. 아래 동작 스펙의 제약을 읽고 켜세요.
들어 올림의 모양 바꾸기
올라온 child 의 크기 · 올라오는 시간 · 뒤를 덮는 정도 · 그림자 · 그림자 상자의 모서리 · 접힐 때 줄어드는
크기는 눈에 보이는 값이라 CoreContextMenuStyle 의 필드입니다. 앱은 메뉴 하나에 contextMenuStyle
로,
모든 컨텍스트 메뉴에 CoreContextMenuTheme.style 로 적고, 적지 않은 필드는 디자인 시스템 기본값
(CoreContextMenuStyle.defaultLiftScale 등)이 그대로 남습니다. 둘이 겹치면 메뉴 하나에 적은 값이 테마를
이기며 필드마다 따로 겨룹니다. 두 플랫폼의 리졸버가 같은 체인에서 같은 값을 정하므로 한 번 적은 값이 양쪽에
똑같이 닿습니다.
ContextMenu(
liftChild: true,
contextMenuStyle: const CoreContextMenuStyle(
liftScale: 1.06,
liftDimOpacity: 0.3,
liftCast: CoreShadow.xl,
liftRadius: CoreBorderRadius.all(CoreRadiusRole.panel),
retractScale: 0.5,
),
items: [
CoreMenuItem.action('Open', onPressed: () {}),
CoreMenuItem.action('Share', onPressed: () {}),
],
child: const Text('Press and hold'),
)
ContextMenu(
liftChild: true,
contextMenuStyle: const CoreContextMenuStyle(
liftScale: 1.06,
liftDimOpacity: 0.3,
liftCast: CoreShadow.xl,
liftRadius: CoreBorderRadius.all(CoreRadiusRole.panel),
retractScale: 0.5,
),
items: [
CoreMenuItem.action('Open', onPressed: () {}),
CoreMenuItem.action('Share', onPressed: () {}),
],
child: Text('Press and hold'),
)
모든 컨텍스트 메뉴를 한 번에 바꾸려면 테마에 적습니다. 메뉴 하나가 같은 필드를 적으면 그 메뉴는 자기 값을 씁니다.
CoreComponentTheme(
contextMenu: CoreContextMenuTheme(
style: CoreContextMenuStyle(liftScale: 1.06, retractScale: 0.5),
),
)
스타일에 없는 값: 누르기가 확정되기까지의 시간(CoreContextMenuContract.defaultLongPressDuration
500ms),
올림이 시작하기 전의 기다림(defaultLiftDelay 150ms), 허용 거리(defaultLongPressSlop
18),
누르기 없는 열기도 올리는가(defaultLiftsWithoutHold)는 제스처가 언제 무엇을 하는지 정하는 값이지 모양이
아니므로 스타일이 아니라 계약에 있습니다. 두 플랫폼이 같은 시간을 기다려야 같은 손가락 움직임이 같은 결과를
내므로, 스타일로 한쪽에서만 다르게 만들 수 없게 둡니다.
liftDuration 은 길게 누른 경우에 한해 확정까지 남은 시간(기본 350ms)을 넘지 못합니다. 올림이 확정 전에
끝나 있어야 메뉴가 올림의 끝으로 읽히기 때문이며, 더 긴 값을 적어도 그 시간으로 줄어 실행됩니다.
빠른 오버라이드 (Chain)#
이미 만든 ContextMenu 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class ContextMenuChainExample extends StatelessWidget {
const ContextMenuChainExample({super.key});
@override
Widget build(BuildContext context) {
return ContextMenu(
items: [
CoreMenuItem.action('Copy', onPressed: () {}),
CoreMenuItem.action('Paste', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Delete', onPressed: () {}),
],
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: context.theme.colorScheme.outline.toValue(),
width: CoreStrokeWidth.stroke1,
),
borderRadius: BorderRadius.circular(CoreRadius.radius8),
),
child: Padding(
padding: const CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space16,
).toValue(geometry: Theme.of(context).geometry),
child: Text('Right-click here').bodyMedium,
),
),
).withStyle(
const CoreContextMenuStyle(
menuStyle: CoreMenuStyle(
itemStyle: CoreMenuItemStyle(
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space12,
),
contentGapStyle: CoreGapStyle(size: CoreSpace.space12),
),
labelTextStyle: CoreTextStyle.token(
CoreTextStyles.bodyMedium,
color: CoreColor.token(CoreColors.onSurface),
),
leadingColumnWidth: CoreSize.size24,
),
),
);
}
}
class ContextMenuChainExample extends StatelessComponent {
const ContextMenuChainExample({super.key});
@override
Component build(BuildContext context) {
return ContextMenu(
items: [
CoreMenuItem.action('Copy', onPressed: () {}),
CoreMenuItem.action('Paste', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Delete', onPressed: () {}),
],
child: div(
[Text('Right-click here').bodyMedium],
// `border-outline` matches the Flutter example's explicit
// `outline` border colour — bare `border` would fall back to the
// current text colour, rendering a darker box on Web only.
classes:
'border border-outline rounded-${CoreRadius.scale.radius8} '
'px-${CoreSpace.scale.space24} py-${CoreSpace.scale.space16}',
),
).withStyle(
const CoreContextMenuStyle(
menuStyle: CoreMenuStyle(
itemStyle: CoreMenuItemStyle(
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space12,
),
contentGapStyle: CoreGapStyle(size: CoreSpace.space12),
),
labelTextStyle: CoreTextStyle.token(
CoreTextStyles.bodyMedium,
color: CoreColor.token(CoreColors.onSurface),
),
leadingColumnWidth: CoreSize.size24,
),
),
);
}
}
Live Preview#
코드 보기
class ContextMenuDefaultExample extends StatelessComponent {
const ContextMenuDefaultExample({super.key});
@override
Component build(BuildContext context) {
return ContextMenu(
items: [
CoreMenuItem.action('Copy', onPressed: () {}),
CoreMenuItem.action('Paste', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Delete', onPressed: () {}),
],
child: div(
[Text('Right-click here').bodyMedium],
classes: 'border rounded-${CoreRadius.scale.radius8} '
'px-${CoreSpace.scale.space24} py-${CoreSpace.scale.space16}',
),
);
}
}
class ContextMenuDefaultExample extends StatelessWidget {
const ContextMenuDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return ContextMenu(
items: [
CoreMenuItem.action('Copy', onPressed: () {}),
CoreMenuItem.action('Paste', onPressed: () {}),
const CoreMenuItem.divider(),
CoreMenuItem.action('Delete', onPressed: () {}),
],
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: context.theme.colorScheme.outline.toValue(),
width: CoreStrokeWidth.stroke1,
),
borderRadius: BorderRadius.circular(CoreRadius.radius8),
),
child: Padding(
padding: const CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space24,
vertical: CoreSpace.space16,
).toValue(geometry: Theme.of(context).geometry),
child: Text('Right-click here').bodyMedium,
),
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 우클릭으로 항목별 액션 메뉴를 제공할 때
- 파일 / 행 / 카드 등에 상황별 액션이 필요할 때
대신 다른 컴포넌트를 사용하세요:
- DropdownMenu: 포인터 위치 대신 버튼 트리거에 붙여 여는 메뉴일 때
- Menubar: 가로 바의 여러 메뉴 트리거를 전환해야 할 때
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. 메뉴의 CoreMenuItem<W>
도 같은 콘텐츠 타입을 사용합니다.
ContextMenu#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child | W | 필수 | 우클릭 트리거 영역 |
items |
List<CoreMenuItem<W>> |
필수 |
컨텍스트 메뉴 패널의 행 —
Menu
/
DropdownMenu
/
Menubar
와 공유하는 통합 [CoreMenuItem] 모델
|
enabled |
bool |
true |
컨텍스트 메뉴 활성화 여부 |
preview |
W? |
null |
메뉴가 열려 있는 동안 메뉴 옆에 떠 있는 위젯 — 메뉴가 가리키는 대상을 더 크게 보여준다. 위치는 Core 규칙 하나가 정한다(아래 미리보기) |
glideToSelect |
bool |
false |
길게 눌러 연 손가락을 떼지 않고 움직여 행을 고른다(아래 글라이드). 마우스·키보드·controller 로 연 메뉴에는 영향이 없다 |
haptics |
bool |
false |
글라이드의 하이라이트가 행에 들어설 때와 행을 골라 실행할 때, 그리고
liftChild
와 함께 길게 눌러 연 메뉴에서 누르기가 확정되는 순간의 선택 햅틱(아래 선택 햅틱).
glideToSelect
도
liftChild
도 없이는 아무것도 하지 않는다
|
liftChild |
bool |
false |
메뉴가 열려 있는 동안
child
를 페이지에서 들어 올리고, 닫힐 때 메뉴를
child
안으로 접는다(아래 들어 올리기). 길게 누르면 메뉴보다 먼저 올라오고, 그 밖의 방법으로 열면 메뉴와 함께 올라온다.
child
가 열려 있는 동안 두 번 빌드된다
|
contextMenuStyle |
CoreContextMenuStyle? |
null |
모든 chrome 이 지나는 단일 슬롯 — 패널 chrome 은
contextMenuStyle.menuStyle
로 흐르고, 들어 올림의 모양(크기 · 시간 · 덮개 · 그림자 · 모서리 · 접힘 크기)은
lift*
·
retractScale
필드로 흐른다(아래 들어 올림의 모양 바꾸기). 테마(
CoreContextMenuTheme.style
)로 모든 컨텍스트 메뉴에 한 번에 적을 수도 있다
|
controller |
PopoverController? |
null |
외부에서 열기/닫기를 명령형으로 제어. 포인터 없이 열면 child의 중앙을 기준으로 함 |
CoreContextMenuStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
menuStyle |
CoreMenuStyle? |
Nested style for the composed
Menu
— the whole menu chrome (panel box via [CoreMenuStyle.popupStyle], the per-row
MenuItem
chrome via [CoreMenuStyle.itemStyle], the divider, the heading typography …). Raw-forwarded to the inner
Menu
's
menuStyle
(the
Menu
resolver merges it over
CoreMenuTheme.style
);
null
leaves the inner
Menu
at its design-system defaults.
|
liftScale |
double? |
The size the lifted child reaches, as a multiple of its own.
null
defers to [defaultLiftScale]. Held to 1 or more by the engine (
CoreContextMenuLift.scale
): a smaller number would make the child shrink as it "rises".
|
liftDuration |
Duration? |
How long the lift takes to reach its full size, and to come back from where it got to when a press ends early.
null
defers to [defaultLiftDuration]. A touch's rise has to end before the hold does, so that the child is fully lifted when the menu appears. A longer duration is therefore held to what the hold leaves after the delay — on both seats, from one Core function (
CoreContextMenuLift.riseDuration
), and never to more than stated here. An opening with no hold has nothing to finish before and rises over the duration stated. Reduced motion reads the duration stated here, by the tier it belongs to (
CoreReducedMotion
): a rise of at most
transitionCeilingMs
is transition motion and becomes a jump (the lifted state is still shown); a longer one is display timing the policy leaves alone, and a held touch's is then shortened to the room the hold leaves like any other.
|
liftDimOpacity |
double? |
How dark the page behind the lifted child gets at full lift, as a share of the
scrim
colour.
null
defers to [defaultLiftDimOpacity]. Held to 0..1 by the engine (
CoreContextMenuLift.dim
).
|
liftCast |
List<CoreShadowLayer>? |
The shadow the lifted child casts at full lift.
null
defers to [defaultLiftCast]; an empty list lifts the child without a cast.
|
liftRadius |
CoreBorderRadius? |
The corner radius of the box the lifted child's shadow is cast from.
null
defers to [defaultLiftRadius].
|
retractScale |
double? |
The size the menu panel and the preview shrink to as they fold back into the child, as a multiple of their own.
null
defers to [defaultRetractScale]. Held to 0..1 by the engine (
CoreContextMenuRetract.endScaleOf
).
|
CoreMenuItem (컨텍스트 메뉴에서 쓰는 종류)#
컨텍스트 메뉴는 통합 CoreMenuItem 모델을 받는다. 주로 쓰는 두 종류:
| 생성자 | 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|---|
.action |
label (positional) |
String |
필수 | 액션 행 텍스트 |
.action |
shortcut |
String? |
null |
라벨 뒤에 표시되는 단축키 힌트 |
.action |
disabled |
bool |
false |
true 시 muted 처리 + onPressed 미호출 |
.action |
onPressed |
void Function()? |
null |
활성 액션 선택 시 호출 |
.divider | — | — | — | 그룹 사이 구분선 |
스타일 시스템 (Style System)#
ContextMenu의 메뉴 패널은 떠 있는 popup 표면이므로 CorePopupStyle
chrome 슬롯을 공유합니다. 엔트리 행 chrome 은
CoreDropdownMenuStyle의 defaultEntryPadding / defaultEntryBorderRadius
/
defaultEntryHoverColor / defaultDividerColor /
defaultEntryDuration
디자인 시스템 기본값으로 고정됩니다.
Resolve chain#
CorePopupStyle.defaultX // 디자인 시스템 기본값
→ CorePopupTheme.style // 프로젝트 공통
→ widget.popupStyle // 인스턴스별
들어 올림의 모양은 같은 방식으로 CoreContextMenuStyle 에서 정해집니다. 양쪽 리졸버가 이 체인을 한 번 풀어
(Flutter resolveContextMenu → ResolvedContextMenu, Web
resolveContextMenu → ResolvedContextMenuRuntime)
위젯과 복사본의 프레임이 읽게 하며, 위젯이 기본값을 쥐거나 테마를 직접 읽지 않습니다.
CoreContextMenuStyle.defaultX // 디자인 시스템 기본값 (liftScale · liftDuration · liftDimOpacity
// · liftCast · liftRadius · retractScale)
→ CoreContextMenuTheme.style // 프로젝트 공통
→ widget.contextMenuStyle // 인스턴스별 (필드마다 따로 이긴다)
동작 스펙 (Behavior)#
인터랙션#
- 우클릭: 자식 영역 우클릭 시 포인터 위치에 메뉴 열림
-
길게 누르기: 터치(또는 펜)를
CoreContextMenuContract.defaultLongPressDuration(500ms) 동안 누르고 있으면 누른 지점에 메뉴가 열립니다. 그 전에 떼거나, 누른 채CoreContextMenuContract.defaultLongPressSlop(18 — Flutter 는 논리 픽셀, Web 은 CSS 픽셀)보다 멀리 움직이면 열리지 않고, 한 번 취소된 누르기는 손가락이 돌아와도 취소된 채입니다. 두 플랫폼이 같은 시간·거리·위치(손가락이 처음 닿은 곳)를 씁니다 — 하나로 두지 않으면 같은 손짓이 한쪽에서만 열립니다. Flutter 는 모바일 플랫폼(iOS · Android · Fuchsia)에서만, Web 은 포인터가 터치 · 펜일 때(OS 와 무관) 동작하며 마우스로 오래 누르는 것은 열지 않습니다. Web 은 누르는 동안 페이지나 스크롤 영역이 스크롤되거나 뷰포트 크기가 바뀌어도 취소합니다 — 손가락 아래가 더 이상 처음 누른 곳이 아니기 때문입니다. 시간 값은 전환이 아니라 누름 기준이므로 동작 줄이기 설정으로 줄어들지 않습니다(0 이 되면 모든 터치가 길게 누르기가 됩니다) -
키보드:
child안에서 포커스를 가진 요소에서 컨텍스트 메뉴 키 또는Shift+F10을 누르면 그 요소의 중심에 메뉴가 열립니다(포인터가 없으므로 포인터 위치를 쓸 수 없습니다). 키를 누르고 있어도 한 번만 열리고,enabled: false면 두 키 모두 동작하지 않습니다. 컴포넌트가 탭 정지를 새로 만들지 않으므로child안에 포커스를 받는 요소가 있어야 키가 닿습니다 -
항목 선택: 합성된
Menu의onActivate로 먼저 닫기를 요청하고 항목의onPressed를 실행 - 재트리거: 열린 동안 같은 트리거에서 다시 우클릭하면 닫힘
- 외부 클릭 / Escape: 메뉴 닫힘
- 긴 목록: 항목이 뷰포트가 패널에 남기는 높이보다 길면, 패널이 남는 높이를 모두 차지하고 위쪽 여백에 붙어 열립니다(포인터 위치를 덮을 수 있음). 행은 패널 안에서 스크롤되어 마지막 행까지 닿습니다. 남는 높이에 들어가는 메뉴는 그대로입니다
-
열린 채로
items변경: 열려 있는 동안items가 바뀌면 열린 패널이 새 항목으로 갱신됩니다. 다시 열 필요가 없습니다
글라이드로 고르기 (glideToSelect)#
glideToSelect: true 면 터치 · 펜의 길게 누르기로 연 메뉴에서 그 손가락이 떼지 않고
움직여 행을 고를 수 있습니다. 메뉴는 누른 지점에 바로 열려 손가락이 첫 행 모서리 위에
놓이는데, 손이 방금 덮은 작은 표적을 다시 조준하려고 두 번째 터치를 하지 않아도 되게 하는
동작입니다. 꺼 두면 손가락을 어디서 떼든 메뉴가 열린 채 남아 탭으로 고릅니다.
판정은 상태 기계 하나(CoreContextMenuGlide)가 내리고, 플랫폼은 행의 위치를 재고 · 행을
켜고 · 실행하고 · 메뉴를 닫는 일만 맡습니다. 같은 궤적을 적은 공유 벡터 표
(context_menu_glide_vectors.json)를 Core · Flutter · Web 테스트와 실제 Chrome 프로브가 모두
재생하므로 한쪽만 다른 판정을 내릴 수 없습니다.
| 손가락이 | 일어나는 일 |
|---|---|
메뉴가 열린 지점에서 defaultGlideSlop(18)을 넘지 않은 채 뗀다 |
아무 일도 없고 메뉴는 열린 채 남는다 — 읽으려던 사람이 놓은 것이지 고른 것이 아니다 |
| 그 거리를 넘어 움직인다 | 글라이드가 시작되고 손가락 아래의 활성 행이 눌린 모양으로 켜진다. 시작된 글라이드는 손가락이 처음 자리로 돌아와도 글라이드다 |
| 활성 행 위에서 뗀다 | 그 행이 탭된 것처럼 실행된다. 액션 행은 메뉴를 닫고, 체크박스 · 라디오 행은 탭과 같이 메뉴를 연 채 둔다 |
| 비활성 행 · 구분선 · 행 사이 · 패널 밖에서 뗀다 | 아무것도 실행하지 않고 메뉴를 닫는다 |
| 시스템이 손가락을 가져간다 | 아무것도 고르지 않고 메뉴는 열린 채 남는다 |
- 어느 행인가: 행 상자는 왼쪽·위 변을 포함하고 오른쪽·아래 변을 제외하는 반열린 구간이며, 겹치는 상자가 있으면 먼저 오는 행이 이깁니다. 비활성 행은 건너뛰지 않고 그 행 자체로 쳐서 아무것도 켜지 않습니다 — 손가락이 그 행 위에 있는데 뒤의 행이 켜지면 안 되기 때문입니다.
-
언제 시작인가: 거리는 열린 지점을 기준으로 재며
defaultLongPressSlop과 같은 값을 같은 방식(엄격히 초과)으로 비교합니다. 누르는 동안 흐른 손이 아니라 처음 닿은 곳이 기준입니다. -
언제 끝나는가: 손가락을 떼거나 시스템이 가져갈 때, 그리고 메뉴가 닫힐 때(
Escape· 바깥 탭 · 항목 선택)입니다. 닫힌 메뉴 위에서 손가락을 떼도 아무것도 실행되지 않습니다. -
어떤 메뉴에 닿는가: 길게 눌러 연 메뉴에만 있습니다. 마우스 우클릭 · 키보드 ·
controller로 연 메뉴에는 움직일 손가락이 없어 달라지는 것이 없고, 열린 메뉴를 다시 길게 눌러 닫는 동작도 그대로입니다. - 긴 메뉴: 항목이 뷰포트보다 길어 목록이 스크롤되는 메뉴에서도 글라이드는 동작합니다. 행은 손가락이 움직이거나 뗄 때마다 그때의 위치로 다시 재므로, 다른 손가락이나 휠로 스크롤된 목록에서도 손가락 아래의 행이 켜집니다. 패널 상자 밖으로 스크롤되어 보이지 않는 부분은 대상이 아닙니다. 글라이드가 목록을 스스로 스크롤하지는 않습니다(가장자리 자동 스크롤 없음) — 손가락이 멈춘 채 목록만 스크롤되면 켜진 행은 손가락이 다시 움직이거나 뗄 때 갱신되고, 떼는 순간의 판정은 그때의 위치를 씁니다. 보이지 않는 행은 글라이드로 닿지 않으므로 탭이나 키보드로 고릅니다.
미리보기 (preview)#
preview 는 메뉴가 열려 있는 동안 메뉴 옆에 떠 있는 위젯입니다 — 사진 · 메시지 · 레코드처럼 메뉴가
가리키는 대상을, 손가락이나 행이 가리는 원래 자리보다 크게 보여줍니다. null 이면 아무것도 그리지
않습니다.
-
어디에: 메뉴가 엔진에 의해 포인터에 열리고 충돌 처리(뒤집기 · 밀기)를 거친 뒤에 자리를 받으므로, 미리보기는 메뉴가 실제로 놓인 자리를 기준으로 합니다. 규칙은 Core 에 한 번만 적혀 있고(
CoreContextMenuPreview.place) 두 플랫폼이 같은 함수를 부릅니다. 메뉴가 놓이기 전에는 그리지 않습니다. - 순서: 위 → 아래 → 끝 → 시작. 미리보기를 통째로 담는 첫 쪽이 이깁니다(끝은 왼쪽에서 오른쪽으로 쓰는 글에서 오른쪽, 오른쪽에서 왼쪽으로 쓰는 글에서 왼쪽). 세로 쪽이 먼저인 것은 이 기능이 필요한 화면이 폭 240–280 의 메뉴 옆 폭이 모자란 폰이기 때문입니다.
- 어느 쪽도 담지 못하면: 남는 폭·높이가 가장 큰 쪽이 이기고(같으면 앞선 쪽) 그 쪽의 남는 만큼으로 줄어듭니다. 메뉴 위를 덮거나 뷰포트를 벗어나지 않으며, 어느 쪽에도 자리가 없으면 그리지 않습니다.
-
간격과 정렬: 메뉴와는
defaultPreviewGap(8) 떨어지고, 세로 쪽에서는 시작 변을 메뉴의 시작 변에, 가로 쪽에서는 윗변을 메뉴의 윗변에 맞춘 뒤 뷰포트 안으로 밀어 넣습니다. - 뷰포트: 각 플랫폼이 메뉴 자신에게 쓰는 방과 같습니다 — Flutter 는 창에서 가장자리 여백 · 시스템 바 · 키보드를 뺀 영역, Web 은 가장자리 여백을 뺀 뷰포트입니다.
-
어떤 모양인가: 떠 있는 패널 chrome(
Popup) 안에preview를 담습니다.Popup의 최소 폭(128)이 있으므로 아주 작은 미리보기도 그보다 좁은 패널은 되지 않습니다. 포인터를 받지 않으므로 미리보기를 탭하는 것은 메뉴 밖을 탭하는 것과 같아 메뉴가 닫히고, 포커스도 받지 않으며, 메뉴의 열림 · 닫힘과 같은 시간에 나타나고 사라집니다. 글라이드하는 손가락은 미리보기 위를 지나 행에 닿습니다. - 열린 채 바뀌면: 호스트가 다른
preview로 다시 그려지면 열린 미리보기도 따라갑니다.
선택 햅틱 (haptics)#
haptics: true 면 글라이드의 하이라이트가 새 행에 들어설 때(빈 곳에서든 다른 행에서든)와 손가락을 떼어
행을 골라 실행할 때 한 번씩 선택 틱(CoreHapticCue.selectionTick)을 재생합니다. 행을 벗어나 빈 곳으로
가는 움직임, 한 행 안에서의 움직임은 조용합니다. 탭에는 시스템 자신의 피드백이 있고 틱은 손가락이
움직이는 동안의 것이므로 glideToSelect 없이는 아무것도 재생하지 않습니다.
liftChild 가 함께 켜져 있으면 길게 눌러 연 메뉴에서 누르기가 확정되어 메뉴가 열리는 순간에도 한 번
재생합니다 — 올라와 있던 child 가 확정을 알리는 손끝의 짝입니다. 우클릭 · 키보드 · controller
로
여는 메뉴에는 확정할 누르기가 없어 재생하지 않습니다.
햅틱은 얇은 이음매입니다 — 큐 하나(CoreHapticCue), 플랫폼마다 함수 하나, 테스트용 대역 하나이고 세기 눈금 ·
패턴 · 전역 스위치 · 대기열은 없습니다. 플랫폼은 자기가 말할 수 있는 만큼으로 재생합니다.
들어 올리기 (liftChild)#
liftChild: true 는 메뉴가 열린 child 를 페이지에서 들어 올려, 메뉴가 무엇에 대한 것인지를 눈에 보이게
합니다. 길게 누르는 손에는 하나가 더 있습니다 — 500ms 를 기다리는 동안 아무 일도 보이지 않으면 누르기가
인식되고 있는지 알 수 없는데, 올라오는 child 가 그 기다림을 보여 줍니다.
판정은 Core 가 한 번만 적습니다. 시각마다의 진행(CoreContextMenuLift.progress), 그 진행이 어떤 크기 ·
그림자 · 덮개인지, 패널이 어디로 얼마나 접히는지(CoreContextMenuRetract), 그리고 누르기가 취소되고 확정되는
순서를 가진 시간선(CoreContextMenuLiftTimeline)이 그것입니다. 플랫폼은 시계와 프레임, 원본의 상자, 그리는 일만
맡습니다. 같은 시각마다의 값을 적은 공유 벡터 표(context_menu_lift_vectors.json)를 Core 테스트가 함수에,
Flutter 테스트가 실제 포인터 · 키 이벤트에, Web 테스트가 수동 시계의 터치 트래커에, Chrome 프로브가 실제 터치
입력의 계산된 스타일에 재생하므로 한쪽만 다른 값을 낼 수 없습니다. 표는 기본 숫자와, 다른 숫자를 적은
스타일(override) 아래의 같은 재생을 둘 다 담습니다.
판정에 들어가는 숫자(크기 · 시간 · 덮개 · 그림자 · 모서리 · 접힘 크기)는 함수가 인자로 받습니다. 엔진은 기본값을 스스로 쥐지 않으므로 리졸버가 정한 값을 넘기지 않은 쪽은 컴파일되지 않고, 스타일이 적은 값이 한 플랫폼에만 닿는 일이 없습니다.
| 시각(길게 누른 지점에서) | 일어나는 일 |
|---|---|
0 – defaultLiftDelay(150ms) |
아무것도 보이지 않는다. 탭이나 스크롤의 시작은 이 안에서 끝나므로 들어 올림을 보지 못한다 |
| 150ms – 450ms |
liftDuration
(기본 300ms) 동안
CoreEasing.enter
곡선으로 올라온다:
child
가
liftScale
(기본 1.04)배로 커지고, 아래로
liftCast
(기본
CoreShadow.lg
)의 그림자를 드리우며, 뒤의 페이지가
liftDimOpacity
(기본 20%)의 스크림으로 물든다. 크기 · 그림자 · 덮개는 한 진행에 비례하므로 그림자는
child
와 함께 자란다
|
500ms(defaultLongPressDuration) |
누르기가 확정되어 메뉴가 올라와 있는
child
둘레에 열린다. 기본 숫자로는 올라오는 데 450ms 가 걸리므로 확정 전에 끝나 있고, 더 긴
liftDuration
을 적어도 올림은 확정까지 남은 시간으로 줄어 확정 전에 끝난다(아래)
|
확정 전에 떼거나 · defaultLongPressSlop 을 넘기거나 · 시스템이 가져가거나 · 아래가 스크롤되면 |
올라온 곳에서 같은 곡선으로 내려앉는다(남은 만큼의 비율을 올라올 때와 같은 시간에 걸쳐). 150ms 안이면 아무것도 보이지 않았으므로 그대로다 |
| 확정 뒤에 뗀다 | 아무 일도 없다 — 메뉴가 열려 있는 동안 child 는 올라온 채다 |
-
메뉴를 늦추지 않습니다. 우클릭 · 키보드 ·
controller로 여는 메뉴는 기다릴 누르기가 없으므로 지연 없이 메뉴가 열리는 순간 같이 올라옵니다. 메뉴는 들어 올림을 기다리지 않고 열립니다. 이 경우를 올리지 않게 하려면defaultLiftsWithoutHold한 값을 바꿉니다. -
올라오는 시간은 확정을 넘지 않습니다. 길게 누를 때 올림이 확정 전에 끝나 있어야 메뉴가 올림의 끝으로 읽힙니다.
liftDuration은 앱이 바꿀 수 있어 이 순서가 기본 숫자의 사실로 남지 않으므로, Core 의 한 함수(CoreContextMenuLift.riseDuration)가 길게 누른 올림의 시간을 확정까지 남은 시간(defaultLongPressDuration−defaultLiftDelay, 기본 350ms)으로 줄입니다. 적은 값보다 길어지는 일도 음수가 되는 일도 없고, 시간선이 올림을 시작할 때 이 함수를 불러 두 플랫폼이 같은 시간을 씁니다. 누르기 없는 열기에는 끝내야 할 확정이 없어 적은 시간 그대로 올라옵니다. -
범위 밖의 값은 범위로 잡힙니다.
liftScale은 1 이상으로,liftDimOpacity와retractScale은 0..1 로 Core 엔진이 잡습니다. 1 보다 작은 크기는 올라오는 대신 줄어들고, 1 을 넘는 접힘 크기는 패널이 사라지며 커지기 때문입니다. 숫자가 아닌 값(NaN · 무한)은 변화 없음으로 다루어 렌더가 깨지지 않습니다. -
닫는 방법은 모두 같은 접힘입니다:
Escape· 바깥 누르기 · 항목 실행 ·controller.close()· 글라이드의 손 떼기 모두. 복사본은 그때까지의 값에서 패널의 닫힘 시간(closeAnimationDuration)에 걸쳐 내려앉고, 패널과 미리보기는 같은 시간에CoreEasing.exit곡선으로retractScale(기본 0.3)배까지 줄면서 투명해집니다. 패널의 닫힘 시간을 쓰므로popupStyle.closeAnimationDuration로 바꾸면 둘이 함께 바뀝니다. -
어디로 접히는가: 원본 상자 위의 점 중 패널의 중심에 가장 가까운 점입니다(
CoreContextMenuRetract.pivot). 패널이child옆에 열렸으면 그쪽 가장자리의 점으로 빨려 들어가고, 포인터가child안에서 연 메뉴처럼 패널이child위에 겹쳐 열렸으면 패널 자신의 중심이 기준이라 미끄러지지 않고 제자리에서 줄어듭니다. 패널과 미리보기는 같은 점 · 같은 비율을 써서 하나로 접힙니다. 점은 닫기 시작할 때 그때의 상자로 다시 재므로 열려 있는 동안 스크롤된 페이지에서도 지금 있는 원본으로 접힙니다. - 끝나는 순간: 복사본 · 덮개가 사라지고 원본이 다시 보이는 것은 패널이 사라지는 프레임과 같은 프레임입니다 — 한 요소가 먼저 사라지거나 둘이 한 프레임 겹쳐 보이지 않습니다.
-
복사본은 그림입니다. 원본이 있던 자리와 크기에, 메뉴의 배리어 · 패널 아래에 그려지므로 복사본을 누르는 것은 메뉴 밖을 누르는 것입니다. 포인터를 받지 않고, 포커스를 받지 않고, 스크린 리더에 읽히지 않습니다(Flutter:
IgnorePointer·ExcludeFocus·ExcludeSemantics, Web:pointer-events: none·inert·aria-hidden). 사람이 닿고 듣는 것은 원본뿐입니다. - 원본은 그대로입니다. 레이아웃의 자리 · 상태 · 접근성 트리의 위치를 지키고, 복사본이 올라가 있는 동안에만 투명합니다(레이아웃에서 빠지지 않고 접근성 트리에서도 빠지지 않습니다). 누르고 있는 손가락은 계속 원본에 있으므로 길게 누르기도 끊기지 않습니다.
- 복사본은 상태가 없습니다. 같은 위젯 · 컴포넌트를 한 번 더 빌드한 것이라 카운터 같은 상태는 초기값에서 시작하고, 호버 · 포커스 · 눌림 · 스크롤 위치 · 입력된 글자 같은 원본의 그때그때 모습을 갖지 않습니다. 원본의 상태는 열고 닫는 동안 그대로 지켜집니다.
-
비용: 열려 있는 동안(그리고 올라와 있는 동안)
child가 두 번 빌드되고 두 번 그려집니다. 프레임마다 다시 빌드되는 것은 복사본의 변환 · 그림자 · 덮개뿐이고child의 하위 트리는 다시 빌드되지 않습니다(같은 인스턴스). 누르기가 들어 올려지기 전에 끝나는 탭 · 스크롤은 시계 하나만 만들고 복사본은 만들지 않습니다. -
동작 줄이기: 올라옴 · 내려앉음 · 접힘은 전환이므로 즉시로 바뀝니다(
CoreReducedMotion). 올라온 모습 자체는 보입니다 — 누르기를 알리는 피드백이기 때문입니다. 기다림(150ms)은 움직임이 아니라 대기이므로 그대로입니다. 전환으로 보는 기준은CoreReducedMotion.transitionCeilingMs(500ms) 이하의 시간이라, 앱이 그보다 긴liftDuration을 적으면 표시 시간으로 보아 정책이 건드리지 않습니다(길게 누른 경우의 올림은 그래도 확정까지 남은 시간으로 줄어듭니다). -
옵션을 켜고 끄는 일:
child를 감싸는 요소가 달라지므로 켜거나 끄면child하위 트리가 다시 만들어집니다. 올라와 있는 동안 끄면 복사본과 덮개는 곧바로 사라지고(원본을 감싸던 요소도 사라지므로 남겨 두면 둘이 보입니다) 열려 있던 메뉴는 열린 채로 있다가 닫힐 때 접힘 없이 닫힙니다.
제약
-
child안에GlobalKey가 있으면 두 번째 빌드가 같은 키를 쓰게 되어 Flutter 가 거부합니다. 바깥에서 만들어child안에 넘긴FocusNode· 컨트롤러처럼 한 곳에만 붙는 것도 같습니다. 키 없이 스스로 만드는child를 들어 올리세요. -
Web 에서
child안의id는 문서에 두 번 나타납니다(복사본은inert이고aria-hidden이지만 문서에는 있습니다).<form>안에 있는 폼 컨트롤은 제출에 두 번 실릴 수 있습니다.id로 찾는 코드나 폼이child안에 있다면 들어 올리지 마세요. -
Web 은 옵션이 켜져 있는 동안
child를 블록<div>하나로 감쌉니다(들어 올려도child를 다시 만들지 않으려는 것입니다). 인라인 흐름에 섞이는child라면 그 감싸기가 줄 바꿈을 만들 수 있습니다. -
Web 의 복사본은 원본이 잰 크기의 상자에 배치됩니다.
child의 스타일이.card > .child처럼 조상에 의존하면 복사본이 다르게 보일 수 있습니다. -
Web 복사본의 그림자는 완전히 올라왔을 때만
--shadow-lg변수(눈금 위의 단계)이고, 올라오는 중의 값은 눈금 위가 아니라 리터럴이라 서피스 스타일이 그 변수를 구부려도 올라오는 중에는 따라가지 않습니다 — 그림자를 구부리는 스타일에서는 꼭대기에 닿는 순간 그림자가 한 번 바뀝니다. Flutter 의 복사본은 서피스 스타일의 기하를 거쳐 모든 진행에서 구부러집니다(알려진 비대칭이며, 스타일이 그림자를 구부리지 않는 기본에서는 같습니다). - 내려앉는 중에 새 누르기나 새 메뉴가 시작되면 이전 내려앉음은 그 자리에서 끝나고 새 들어 올림이 처음부터 시작합니다.
디자이너 판단 값#
계산해 낸 값이 아니라 느낌으로 정한 값입니다. 각 값은 Core 의 default* 한 곳에만 적혀 있고 코드 주석에도
그렇게 표시되어 있습니다. CoreContextMenuStyle 의 값은 앱이 contextMenuStyle
이나 테마로 바꾸는 모양이고,
CoreContextMenuContract 의 값은 두 플랫폼이 같아야 하는 제스처의 시간이라 스타일에 없습니다.
| 값 | 어디에 | 정한 값 | 바꾸면 |
|---|---|---|---|
| 글라이드가 시작되는 거리 | CoreContextMenuContract.defaultGlideSlop |
18 (defaultLongPressSlop 과 같은 값) |
더 크게(24 · 32) 하면 실수로 글라이드가 시작될 일이 줄지만 첫 행들에 손이 덜 닿는다. 더 작게 하면 길게 누르는 동안 흐른 손이 뗄 때 행을 고른다 |
| 미리보기와 메뉴의 간격 | CoreContextMenuContract.defaultPreviewGap |
8 (CoreSpace.space8) |
12 · 16 이면 한 쌍이 더 떨어져 보이고 4 이면 한 덩어리로 붙는다 |
| 미리보기를 놓는 쪽의 우선순위 | CoreContextMenuPreview.place |
위 → 아래 → 끝 → 시작 | 옆을 먼저 하면 넓은 화면에서 자연스럽지만 폰에서는 폭이 모자라 자주 줄어든다 |
| 들어 올린 크기 | CoreContextMenuStyle.defaultLiftScale |
1.04 | 1.02 면 올라온 것이 거의 읽히지 않고, 1.08 이상이면 child 가 이웃을 덮어 child 가 바뀐 것처럼 보인다 |
| 들어 올림 전의 기다림 | CoreContextMenuContract.defaultLiftDelay |
150ms (CoreDuration.fast) |
줄이면 탭 · 스크롤의 시작에도 child 가 깜박이고, 늘리면 누르기가 인식되고 있다는 피드백이 늦어져 500ms 확정 전에 끝나지 않을 수 있다 |
| 올라오고 내려앉는 시간 | CoreContextMenuStyle.defaultLiftDuration |
300ms (CoreDuration.moderate), 올라올 때와 내려앉을 때 모두 CoreEasing.enter |
짧으면 딱 끊기는 것으로 읽히고, 길면 확정(500ms) 안에 끝나지 못해 길게 누른 경우에는 확정까지 남은 시간(기본 350ms)으로 줄어 실행된다. 곡선을
CoreEasing.move
로 하면 내려앉음이 올라옴의 거울이 된다
|
| 그림자의 세기 | CoreContextMenuStyle.defaultLiftCast |
CoreShadow.lg |
CoreShadow.md 면 낮게 떠 있는 것으로 읽히고 xl 이면 메뉴 패널의 그림자와 겹쳐 둘이 구분되지 않는다 |
| 페이지를 덮는 정도 | CoreContextMenuStyle.defaultLiftDimOpacity |
20% (CoreOpacity.opacity20) |
더 짙으면 뒤의 문맥이 사라지고 옅으면 올라온 child 가 뒤와 갈리지 않는다 |
| 그림자를 드리우는 상자의 모서리 | CoreContextMenuStyle.defaultLiftRadius |
CoreRadiusRole.surface(16) |
child 의 모서리와 다르면 그림자가 child 의 모양을 따르지 않는다 |
| 누르기 없는 열기도 올리는가 | CoreContextMenuContract.defaultLiftsWithoutHold |
예 | 끄면 길게 누르는 경우에만 올라온다 — 마우스 · 키보드로 여는 데스크톱에서는 들어 올림이 보이지 않는다 |
| 접힐 때 줄어드는 크기 | CoreContextMenuStyle.defaultRetractScale |
0.3 | 0.5 면 접힘이 덜 보이는 대신 사라지기 전에 child 를 덮고 있는 시간이 길다 |
| 접힘의 시간과 곡선 | 패널의 closeAnimationDuration(100ms), CoreEasing.exit |
패널 닫힘과 같은 시간 | 따로 두지 않는 이유는 복사본이 내려앉는 시각과 패널이 사라지는 시각이 어긋나면 한쪽이 홀로 남기 때문이다 |
| 확정의 햅틱 | CoreHapticCue.selectionTick |
글라이드의 틱과 같은 큐 | 따로 큐를 두면 두 틱이 서로 다른 세기로 느껴진다 |
| 햅틱의 세기 | CoreHapticCue.selectionTick |
Flutter: 시스템의 선택 틱(세기를 고르지 않음). Web: 10ms | Web 의 길이를 늘리면 틱이 진동으로 읽히고, 모터마다 렌더할 수 있는 최소 길이가 달라 실기기 측정이 필요하다 |
애니메이션#
- 엔트리 hover 배경: 150ms 색상 전환 (
CoreDuration.ms150)
사용 가이드라인 (Usage Guidelines)#
✅ Do#
관련 액션을 구분선으로 그룹화
ContextMenu(
items: const [
CoreMenuItem.action('Cut'),
CoreMenuItem.action('Copy'),
CoreMenuItem.divider(),
CoreMenuItem.action('Delete'),
],
child: const Text('Item'),
)
❌ Don't#
유일한 진입점으로 컨텍스트 메뉴 사용
// ❌ 우클릭으로만 접근 가능한 핵심 액션
ContextMenu(
items: const [CoreMenuItem.action('Save')],
child: editor,
)
핵심 액션은 보이는 버튼으로도 제공해야 한다. 컨텍스트 메뉴는 보조 경로다.
접근성 (Accessibility)#
역할#
-
트리거 영역 — 양쪽 모두 역할이 없습니다. Web 루트
<div>는 callerattributes만 통과시키고 컴포넌트가 role/aria 를 붙이지 않으며, Flutter 는 제스처 감지기(RawGestureDetector)와 단축키(Shortcuts)뿐이라Semantics가 하나도 없습니다. 컨텍스트 메뉴가 있다는 사실 자체가 스크린 리더에 알려지지 않습니다. - 패널 — Web은 role=menu · aria-orientation=vertical, Flutter는 합성된 Menu의 CoUISemantics.menu 역할입니다.
- 엔트리 — Web은 menuitem / checkbox / radio와 aria-checked · aria-disabled를 사용합니다. Flutter는 MenuRowScope 안 MenuItem의 menuItem 역할·enabled·selected로 전달합니다.
-
구분선 — Web
role="separator"+aria-label, FlutterSemantics(container: true, label: …), 라벨은dividerSeparatorLabel(ko: "구분선"). -
미리보기 — 포커스를 받지 않고 포인터를 받지 않지만 접근성 트리에서는 빠지지 않아, 스크린 리더가 일반 콘텐츠로 읽습니다. 메뉴가 아니므로 Web 은 패널 chrome 의 기본
menu역할을presentation으로 바꿉니다.
키보드#
| 키 | 동작 |
|---|---|
컨텍스트 메뉴 키 / Shift+F10 |
child 안에서 포커스를 가진 요소의 중심에 메뉴 열기 (양 플랫폼) |
Escape | 열린 메뉴 닫기 (양 플랫폼) |
Tab / Shift+Tab |
열린 메뉴의 항목 간 이동 — 항목마다 별도 tab stop |
Enter / Space | 포커스된 항목 실행 |
메뉴는 컨텍스트 메뉴 키와 Shift+F10 으로 열립니다. 키는 child 안에서 포커스를 가진 요소에서 눌려야 하고, 메뉴는 그 요소의 중심에 열립니다. 컴포넌트가 탭 정지를 만들지 않기 때문에
child 안에 포커스를 받는 요소가 없으면 키가 컴포넌트에 닿지 않습니다 — 그런 영역의 액션은 보이는 버튼과 controller.show()
로 함께 제공하세요. Flutter 는 Shortcuts + Actions 로 두 키를 받고, Web 은 브라우저가 키에 대해 보내는
contextmenu 이벤트를 받습니다. 그 이벤트가 포인터 좌표를 싣지 않으면(clientX == 0 && clientY == 0) 이벤트가 향한 요소의 중심에 열고, 브라우저가 실제 좌표를 실어 보내면 그 좌표를 씁니다(Chrome 은 컨텍스트 메뉴 키에 포커스를 가진 요소의 중심 좌표를 실어 보냅니다). 컴포넌트가 키를 가로채지 않으므로 Web 에서 어느 키가 그 이벤트를 만드는지는 브라우저와 운영체제가 정합니다 — Flutter 는 두 키를 모든 플랫폼에서 직접 받습니다.
Web 패널은 합성된 Menu의 roving tabindex · 화살표 · Home / End · label typeahead를 상속합니다. Flutter Menu에는 같은 메뉴 전용 탐색이 없습니다.
스크린 리더#
-
트리거 영역에는 접근 가능한 이름도 역할도 없습니다.
child가 그대로 읽힐 뿐, "컨텍스트 메뉴를 가진 영역" 이라는 신호가 없습니다. (Flutter 모바일에서는 길게 누르기 인식기가 걸려 있어 스크린 리더의 길게 누르기 동작이 이 인식기로 이어지고, 메뉴는 영역의 중심에 열립니다. 데스크톱 플랫폼에서는 그 인식기 자체가 붙지 않아 스크린 리더용 동작이 없습니다 — 그 자리는 위의 컨텍스트 메뉴 키가 대신합니다.) -
패널에는 접근 가능한 이름이 없습니다.
Menu의ariaLabel슬롯을ContextMenu가 노출하지 않고 내부에서Menu를 생성하므로 호출부에서 붙일 수 없습니다. -
각 항목 이름은
CoreMenuItem.label입니다. Web 은 비활성 항목을aria-disabled로, Flutter 는Semantics(enabled: false)로 함께 알립니다. -
배경 차단은 양쪽 다 있습니다. Flutter 는 모달 배리어의
BlockSemantics(엔진 기본modal: true), Web 은modal: true마운트가 거는inert입니다. -
닫기 동작도 양쪽 다 있습니다. 배경이 막혀 스크림에 닿을 수 없는 스크린 리더 사용자가 "바깥을 눌러 닫기" 를 대신할 수단입니다. Flutter 는 패널 범위의
Semantics(onDismiss:)가 지역화된dismissLabel(ko: "닫기")을 이름으로 가져 스크린 리더의 닫기 제스처와 그 노드의 활성화가 메뉴를 닫고, Web 은 패널 곁의 시각적으로 숨긴role="button"노드(aria-label이 같은dismissLabel)를 활성화하면 닫힙니다. 터치 스크린 리더에는Escape가 없어 이 노드가 유일한 닫기 수단입니다. Web 노드는tabindex="-1"이라 탭 순서에 들지 않고, 키보드에는Escape가 있습니다. 이 이름은 닫기 동작의 이름이지 메뉴의 이름이 아니므로 위의 "패널에 이름이 없다" 는 제약은 그대로입니다. - 열림/닫힘 상태 통보(live region)는 없고,
aria-expanded를 실을 트리거 요소도 없습니다. -
들어 올린 복사본은 읽히지 않습니다.
liftChild를 켜도 스크린 리더가 읽고 닿는 것은 원본 하나입니다. 복사본은 그림이라 접근성 트리에서 빠지고(FlutterExcludeSemantics, Webaria-hidden+inert), 원본은 투명해도 접근성 트리와 포커스 순서에 그대로 있습니다.
들어 올림과 접근성 설정#
들어 올림은 장식이므로 접근성 설정이 이깁니다.
| 설정 | 처리 |
|---|---|
| 동작 줄이기 |
올라옴 · 내려앉음 · 접힘이 즉시로 바뀝니다(
liftDuration
이 500ms 이하일 때 — 위 동작 줄이기). Flutter 는 안드로이드의 애니메이션 끄기와 iOS 의 동작 줄이기를 모두 읽고(
resolveMotionDuration
), Web 은
prefers-reduced-motion
을 읽습니다(
CoreReducedMotion
). 올라온 모습은 보입니다 — 누르기를 알리는 피드백이기 때문입니다. 기다림은 대기이므로 그대로입니다
|
| 투명도 줄이기 |
따로 처리하지 않습니다. 복사본은
child
자신의 채움이고 덮개는 뒤의 페이지만 물들일 뿐 글자 뒤에 깔리지 않으며, 투명도 줄이기가 걷어 내는 재질(유리)이 아닙니다
|
| 고대비 |
덮개와 그림자는 활성 스킴의
scrim
·
shadow
토큰이라 고대비 스킴이 그 슬롯에 말하는 대로 그려집니다. 따로 분기하지 않습니다
|
| 강제 색 모드(Web) |
반투명 채움은 불투명한 시스템 색으로 바뀌어 페이지를 가리므로 덮개를 뺍니다(호스트 스타일시트의
forced-colors
규칙). 그림자는 브라우저가 끄고, 복사본과 메뉴는 그대로입니다. Flutter 에는 강제 색 모드가 없습니다
|
포커스 관리#
- 진입 — Flutter는 모달 오버레이의 패널 스코프로 포커스를 요청합니다. Web은 열릴 때 포커스를 옮기지는 않지만, 패널 안에서 Menu 키 탐색으로 행 포커스를 옮깁니다.
-
이탈 — 양쪽 모두 우클릭 지점이나 트리거로 포커스를 되돌리는 코드는 없고, 결과가 다릅니다. Flutter 는 패널 스코프가 dispose 되면서 프레임워크의 포커스 복원으로 열기 전에 포커스를 가졌던 위젯으로 돌아갑니다(키보드로 연 메뉴를
Escape로 닫는 테스트가 고정). Web 은 메뉴가 열려 있는 동안 배경이inert라 포커스가body로 떨어지고, 닫은 뒤에도 돌아가지 않습니다(Chrome 에서 키보드로 연 메뉴를Escape로 닫은 뒤document.activeElement가body임을 측정). -
트랩 — Flutter: 패널의
FocusScope가closedLoop기본값을 가져 포커스가 들어간 뒤에는Tab이 패널 안에서 순환합니다. Web: 배경이inert라 배경으로 넘어가지 않지만 패널 안에서 순환하지도 않습니다 — 마지막 항목에서Tab은 브라우저 UI 로 나갔다가 돌아옵니다.
알려진 제약#
-
키보드로 여는 경로는
child안에 포커스를 받는 요소가 있을 때만 있습니다. 컴포넌트가 탭 정지를 만들지 않으므로(영역마다 탭 정지가 하나 늘면 모든 화면의 탭 순서가 바뀝니다) 포커스를 받을 수 없는child에는 키가 닿지 않습니다. 두 플랫폼 모두controller(PopoverController)를 받으므로, 그런 영역의 액션은 보이는 버튼과controller.show()로 같은 액션을 함께 제공하세요. -
Web 은 키보드로 연 메뉴를 닫아도 포커스를 되돌리지 않습니다. 열려 있는 동안 배경이
inert라 포커스를 잃고 닫은 뒤에도body에 남으므로, Web 의 키보드 사용자는 닫은 뒤Tab으로 자리를 다시 찾아야 합니다. Flutter 는 열기 전 위젯으로 돌아갑니다. -
트리거 영역이 메뉴의 존재를 알리지 않습니다.
aria-haspopup이나 대응Semantics가 없어, 스크린 리더 사용자는 그 영역에 컨텍스트 메뉴가 있다는 것을 알 수 없습니다. - Flutter에는 메뉴 전용 화살표·Home/End·typeahead·roving 탐색이 없습니다. Web Menu의 탐색과 같다고 기대하지 마세요.
-
패널에 이름을 붙일 수 없습니다 —
Menu.ariaLabel이ContextMenu표면에 노출되지 않습니다. -
liftChild를 켜면child가 열려 있는 동안 두 번 빌드됩니다. 복사본은 상태가 없고 원본의 호버 · 포커스 · 눌림 모습을 갖지 않으며,GlobalKey· 중복id· 폼 컨트롤 같은 한 번만 있어야 하는 것이child안에 있으면 쓸 수 없습니다(들어 올리기 절의 제약). - Flutter 패널에는 메뉴 구조가 없습니다. 체크박스·라디오 항목의 체크 상태가 스크린 리더에 노출되지 않고 모든 행이 버튼으로 읽힙니다.
전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.
크로스 플랫폼 차이점 (Platform Differences)#
ContextMenu는 Flutter와 Web이 동일한 child / items / enabled
/
contextMenuStyle API를 공유합니다. 메뉴 패널은 Flutter에서는
PopoverOverlayHandler, Web에서는 루트 OverlayHost의 menu 레이어로 portal되어
양쪽 모두 ancestor overflow에 잘리지 않습니다.
두 플랫폼 모두 우클릭, 길게 누르기, 컨텍스트 메뉴 키 / Shift+F10 으로 메뉴를 열고, 같은
지속 시간(500ms)·허용 거리(18)·위치(누른 지점, 키보드는 포커스를 가진 요소의 중심)를 씁니다.
같은 값은 CoreContextMenuContract 가 한 번만 적고 두 플랫폼이 읽으며, 공유 벡터 표가 둘의 판정을
함께 고정합니다. 메커니즘만 다릅니다.
| Flutter | Web | |
|---|---|---|
| 우클릭 | onSecondaryTapDown | DOM contextmenu |
| 길게 누르기 | LongPressGestureRecognizer — 모바일 플랫폼(iOS · Android · Fuchsia)에서만 |
pointerdown 타이머 — 포인터가 터치 · 펜일 때(OS 와 무관) |
| 키보드 | Shortcuts + Actions |
DOM contextmenu (좌표가 없으면 대상 요소의 중심) |
| 스크린 리더의 닫기 | 모달 패널의 dismiss 접근성 동작 |
패널 곁의 숨긴 role="button" 노드 (탭 순서 밖) |
Web 이 길게 누르기를 직접 재는 것은 브라우저의 contextmenu 이벤트가 터치에서 믿을 수 없기
때문입니다 — 길게 누르는 동안 보내는 브라우저, 손가락을 뗄 때 보내는 브라우저, 보내지 않는
브라우저가 있어 이벤트에 맡기면 열리는 시점이 브라우저마다 달라집니다. 같은 제스처에 브라우저가
보내는 contextmenu 는 누르기가 이미 처리한 것으로 보고 무시합니다(그 이벤트를 다시 열기로 세면
방금 연 메뉴가 토글로 닫힙니다). 메뉴를 연 누르기의 손가락 떼기는 touchend 의 기본 동작을 막아
브라우저가 그 터치로 만드는 click 이 페이지에 가지 않게 합니다 — Flutter 에서 길게 누르기가
제스처를 이겨 아래 내용이 탭을 받지 않는 것과 같습니다.
긴 메뉴는 양쪽 모두 뷰포트가 남기는 높이 안에서 스크롤됩니다. Flutter 는 엔진이 패널 최대 높이를
레이아웃 제약으로 제한하며 여백·시스템 바·키보드를 뺍니다. Web 은 위치 엔진이 패널을 측정한 뒤
패널 상자에 max-height 를 걸고 상자 안의 목록을 overflow-y: auto
로 스크롤하며 뷰포트
여백(viewportPadding)만 뺍니다. 스크롤하는 것은 목록뿐이라 상자의 테두리·모서리·그림자와 글래스
림이 행과 함께 밀려나지 않습니다.
글라이드 · 미리보기 · 햅틱도 같은 Core 판정을 두 플랫폼이 읽고, 메커니즘만 다릅니다. 판정(어느 행이 켜지는가, 뗐을 때 무엇이 일어나는가, 미리보기가 어느 쪽에 놓이는가)은 Core 에 한 번만 적혀 있고 공유 벡터 표가 둘을 함께 고정합니다.
| Flutter | Web | |
|---|---|---|
| 글라이드가 있는 곳 | 길게 누르기가 있는 모바일 플랫폼(iOS · Android · Fuchsia) | 포인터가 터치 · 펜일 때(OS 와 무관) |
| 손가락을 따르는 법 | 길게 누르기 인식기가 이긴 뒤에도 그 포인터의 이벤트는 계속 오므로, 포인터 라우터에 그 포인터 번호의 경로를 걸어 Core 상태 기계에 넘깁니다 |
누르는 순간 건 문서 수준 리스너가 그 포인터 번호의
pointermove
·
pointerup
·
pointercancel
을 받아 같은 상태 기계에 넘깁니다 — 손가락이 영역을 벗어나도 따라옵니다
|
| 행의 위치 | 움직임마다 열린 패널의 각 MenuItem 이 렌더 상자를 전역 좌표로 잽니다. 패널 상자로 자릅니다 |
움직임마다 패널 DOM 의 행 요소를
getBoundingClientRect
로 잽니다. 메뉴 상자와 행을 스크롤하는 모든 목록의 상자(중첩 메뉴의 행은 둘)로 자릅니다
|
| 켜진 행 | MenuItem 이 눌린 상태로 그립니다 |
행에 data-coui-glide 가 붙고 눌림과 같은 규칙이 그립니다 — 요소에 직접 쓰지 않고 마크업으로 내므로 다음 렌더가 지우지 않습니다 |
| 행 실행 | 행의 onPressed 를 부릅니다 |
행의 click() 을 부릅니다 — 항목 핸들러 · 토글의 뒤집기 · 메뉴가 먼저 닫히는 순서가 탭과 같은 길입니다 |
| 페이지 스크롤 | 길게 누르기가 제스처를 이겨 스크롤러가 포기하므로 막을 것이 없습니다 |
글라이드 중
touchmove
의 기본 동작을 막아 페이지가 손가락을 따라 스크롤되지 않게 합니다(글라이드를 켠 메뉴에만 리스너가 붙습니다). 이미 브라우저가 스크롤로 가져간 터치는
pointercancel
로 글라이드가 끝납니다
|
| 햅틱 | HapticFeedback.selectionClick — 세기는 시스템이 정합니다 |
navigator.vibrate(10)
— 길이만 정할 수 있습니다. Safari 에는 함수가 없어 아무 일도 없고, 브라우저는 첫 탭이나 키 입력 같은 사용자 활성화 전의 진동을 거부할 수 있으며, 데스크톱 브라우저는 호출이 성공해도 느껴지지 않습니다
|
| 미리보기를 담는 곳 | 루트 오버레이의 두 번째 항목 — 메뉴 항목들 위 | 메뉴 포털 항목의 두 번째 래퍼 — 메뉴와 같이 최상위 레이어로 올라가고 메뉴와 함께 사라집니다 |
| 미리보기를 놓는 방 | 창에서 가장자리 여백 · 시스템 바 · 키보드를 뺀 영역 | 창에서 가장자리 여백을 뺀 뷰포트 |
| 미리보기 크기를 재는 법 | 렌더 객체가 자연 크기로 배치해 본 뒤, 규칙이 줄이면 그 상자 안에서 다시 배치합니다 |
자연 크기를 재려고 원점에 잠시 두고 재서 그 자리에서 되돌립니다. 줄여야 하면
max-width
·
max-height
와
overflow: hidden
으로 가둡니다
|
두 플랫폼 모두 미리보기는 메뉴가 자리를 받은 뒤에야 보이며, 메뉴가 닫히면 메뉴와 같은 시간 동안 사라집니다. 미리보기의 위치를 메뉴 쪽에서 정해 주는 장치는 없습니다 — 메뉴는 원래 자리에 열리고 미리보기가 그 옆을 찾아갑니다.
들어 올리기도 같은 Core 판정(시각마다의 진행 · 크기 · 그림자 · 덮개, 접힘의 점과 비율)을 두 플랫폼이 읽고, 시계 · 상자를 재는 법 · 그리는 법만 다릅니다.
| Flutter | Web | |
|---|---|---|
| 기다림을 아는 법 |
길게 누르기 인식기의
onLongPressDown
이 시작하고, 취소 · 인식기의 포기 · 다른 인식기(스크롤)가 가져가 인식기가 죽는 것 · 포인터의 끝을 포인터 라우터가 알려 내려놓습니다
|
터치 트래커가 누르기를 기다리기 시작할 때와 확정 없이 끝날 때(뗌 · 가져감 · 슬롭 · 스크롤 · 크기 변경)를 알려 줍니다 |
| 시계 | Ticker — 눌린 뒤 첫 프레임부터 |
requestAnimationFrame + performance.now() |
| 올라가 있는 동안 | 완전히 올라오면 시계를 멈추고 닫을 때 이어 갑니다 | 같습니다 — 변하는 것이 없으면 프레임을 요청하지 않습니다 |
| 원본의 자리 | LayerLink 로 원본을 따라가므로 스크롤되어도 겹쳐 있습니다 |
재서 position: fixed 로 두고 스크롤 · 크기 변경 때 다시 잽니다 |
| 복사본이 있는 층 | 루트 오버레이에 메뉴의 항목들보다 먼저 넣은 항목 — 넣는 순서가 위아래를 정합니다 | 메뉴 루트의 하위 요소로 두고 최상위 레이어(popover)에 패널보다 먼저 올립니다 — 올리는 순서가 위아래를 정합니다 |
| 포털 때문에 | 해당 없음 | 포털이 패널의 노드를 매 렌더 새로 만들므로 복사본은 포털 밖 자기만의 상태를 가진 컴포넌트로 두어 프레임마다 자기 하위 트리만 다시 그립니다 |
| 원본 숨기기 | Opacity(0, alwaysIncludeSemantics) — 리더 층이 그려지도록 대상은 그 바깥에 둡니다 |
감싼 <div> 의 opacity: 0 |
| 그림자 | 상자를 오려 낸 캐스트(InjectedCast) — 반투명한 채움이 자기 그림자를 비추지 않습니다 |
box-shadow (바깥쪽만 그립니다) |
| 패널의 접힘 |
패널을
Transform
+
Opacity
로 감싸 엔진의 닫힘 애니메이션을 끄고(
hostPaintsClose
) 직접 그립니다
|
co-overlay-retract 키프레임 — 전환이 아니라 애니메이션인 것은 포털이 노드를 다시 만들기 때문입니다 |
| 접히는 점 | CoreContextMenuRetract.pivot |
같은 함수 — 닫기 시작할 때 getBoundingClientRect 로 잰 상자에 씁니다 |
| 서피스 스타일이 굽히는 그림자 | 모든 진행에서 닿습니다 | 완전히 올라왔을 때만 닿습니다 (올라오는 중은 리터럴) |
| 스타일의 숫자가 정해지는 곳 |
resolveContextMenu
가
ResolvedContextMenu
의
lift*
·
retractScale
로 내고, 복사본의 프레임(
resolveContextMenuLift
)이 그것을 읽습니다
|
resolveContextMenu
가
ResolvedContextMenuRuntime
으로 내고, 복사본의 프레임(
resolveContextMenuLift
)과 접힘(
resolveContextMenuRetractMotion
)이 그것을 읽습니다
|
| 동작 줄이기 | resolveMotionDuration |
CoreReducedMotion + 전환 단계 CSS 변수 |
| 강제 색 모드 | 없음 | 덮개를 뺍니다 |
관련 컴포넌트 (Related Components)#
- DropdownMenu: 버튼 클릭 드롭다운
- Menu: 사이드바 형태의 일반 메뉴
- Popup: 플로팅 패널 컨테이너