Clickable
임의의 child 를 인터랙티브하게 만드는 크로스플랫폼 클릭 래퍼 (인프라)
목차
child 를 감싸 인터랙티브하게 만드는 인프라 프리미티브입니다 — 포인터 커서, 포커스 링, press 피드백 스케일, 키보드 활성화(Enter/Space)를 직접 소유합니다.
Button 같은 상위 컴포넌트가 이 위에 쌓입니다. 직접 쓸 일은 드물지만, 커스텀 클릭 영역을 만들 때 raw GestureDetector
대신 사용하면 양 플랫폼 동작이 통일됩니다.
Live Preview#
코드 보기
class ClickableDefaultExample extends StatelessComponent {
const ClickableDefaultExample({super.key});
@override
Component build(BuildContext context) {
return Clickable(
onPressed: () {},
child: Card(child: Text('클릭 가능한 영역').bodyMedium),
);
}
}
class ClickableDefaultExample extends StatelessWidget {
const ClickableDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return Clickable(
onPressed: () {},
child: Card(child: Text('클릭 가능한 영역').bodyMedium),
);
}
}
사용법#
Clickable(
onPressed: () {},
child: Card(child: Text('클릭 가능한 영역').bodyMedium),
)
빠른 오버라이드 (Chain)#
이미 만든 Clickable 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius9999처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius9999 ==
CoreRadius.radius9999) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class ClickableChainExample extends StatelessWidget {
const ClickableChainExample({super.key});
@override
Widget build(BuildContext context) {
return Clickable(
onPressed: () {},
child: Card(child: Text('클릭 가능한 영역').bodyMedium),
)
.withStyle(
const CoreClickableStyle(
disabledOpacity: CoreOpacity.opacity30,
duration: Duration(milliseconds: CoreDuration.fast),
pressDuration: Duration(milliseconds: CoreDuration.instant),
),
)
.radius16;
}
}
class ClickableChainExample extends StatelessComponent {
const ClickableChainExample({super.key});
@override
Component build(BuildContext context) {
return Clickable(
onPressed: () {},
child: Card(child: Text('클릭 가능한 영역').bodyMedium),
)
.withStyle(
const CoreClickableStyle(
disabledOpacity: CoreOpacity.opacity30,
duration: Duration(milliseconds: CoreDuration.fast),
pressDuration: Duration(milliseconds: CoreDuration.instant),
),
)
.radius16;
}
}
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다.
콘텐츠 · 상태#
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child |
W |
필수 | 인터랙티브하게 만들 콘텐츠 |
enabled |
bool |
true |
비활성화 시 인터랙션 차단 |
focusOutline |
bool |
true |
포커스 시 링 표시 |
enableFeedback |
bool |
true |
press 피드백(스케일/translate) 사용 여부 |
marginAlignment |
AlignmentGeometry? / CoreAlignment? |
null |
margin 안에서 clickable 정렬 (설정 시 Align 으로 감쌈) |
clickableStyle |
CoreClickableStyle? |
null |
커서 / 피드백 chrome 단일 진입점 (아래 표 참고) |
탭 · 포인터 콜백#
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
onPressed |
void Function()? |
null |
기본 탭/클릭 핸들러 |
onDoubleTap |
void Function()? |
null |
더블탭 |
onTapDown |
GestureTapDownCallback? / CorePointerCallback? |
null |
포인터 down |
onTapUp |
GestureTapUpCallback? / CorePointerCallback? |
null |
포인터 up |
onTapCancel |
VoidCallback? |
null |
탭 취소 |
onSecondaryTapDown |
GestureTapDownCallback? / CorePointerCallback? |
null |
보조(우클릭) down |
onSecondaryTapUp |
GestureTapUpCallback? / CorePointerCallback? |
null |
보조(우클릭) up |
onSecondaryTapCancel |
VoidCallback? |
null |
보조 탭 취소 |
onTertiaryTapDown |
GestureTapDownCallback? / CorePointerCallback? |
null |
3차(휠클릭) down |
onTertiaryTapUp |
GestureTapUpCallback? / CorePointerCallback? |
null |
3차(휠클릭) up |
onTertiaryTapCancel |
VoidCallback? |
null |
3차 탭 취소 |
롱프레스 콜백#
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
onLongPress |
void Function()? |
null |
롱프레스 |
onLongPressStart |
GestureLongPressStartCallback? / CorePointerCallback? |
null |
롱프레스 시작 |
onLongPressMoveUpdate |
GestureLongPressMoveUpdateCallback? / CoreDragCallback? |
null |
롱프레스 중 이동 |
onLongPressEnd |
GestureLongPressEndCallback? / CorePointerCallback? |
null |
롱프레스 종료 |
onLongPressUp |
VoidCallback? |
null |
롱프레스 후 포인터 up |
onSecondaryLongPress |
VoidCallback? |
null |
보조 버튼 롱프레스 |
onTertiaryLongPress |
VoidCallback? |
null |
3차 버튼 롱프레스 |
상태 변화 콜백#
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
onHover |
CoreValueChanged<bool>? |
null |
hover 상태 변화 |
onFocus |
CoreValueChanged<bool>? |
null |
focus 상태 변화 |
접근성#
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
semanticsButton |
bool |
true |
보조 기술에 버튼으로 알릴지. 더 구체적인 역할을 가진 컴포넌트(checkbox / switch / tab)가
false
로 끄고 자기 역할을 붙입니다. Flutter 는
button:
시맨틱 플래그를, Web 은
role="button"
속성을 내려놓습니다 — 포커스 가능성은 양쪽 모두 그대로입니다
|
semanticsSelected |
bool? |
null |
보조 기술에 알릴 선택 상태. 옵션 목록의 행처럼 "선택된 것" 이 있는 컴포넌트가 넘깁니다.
null
은
false
와 다릅니다 — 선택 개념 자체가 없다는 뜻이라 아무것도 알리지 않습니다. Flutter 는
selected:
시맨틱 플래그, Web 은
aria-selected
속성으로 내보내며 호출자
attributes
가 이깁니다
|
Flutter 전용#
Web 은 브라우저 native focus / cursor / CSS pseudo-class 로 같은 capability 를 제공합니다.
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
focusNode |
FocusNode? |
null |
외부 포커스 노드 (null = 내부 생성) |
mouseCursor |
MouseCursor? |
null |
커서 (null = 활성 click / 비활성 forbidden) |
statesController |
WidgetStatesController? |
null |
외부에서 disabled/hovered/pressed/focused 를 관찰할 때 |
CoreClickableStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
borderRadius |
CoreBorderRadius? |
Focus-ring corner radius override. |
focusOutlineStyle |
CoreFocusOutlineStyle? |
Focus-outline style override. Nested [CoreFocusOutlineStyle] covers ring colour / width / offset / offset-background.
null
(or any unset field on the slot) defers to [defaultFocusOutlineStyle].
|
pressedScale |
double? |
Pressed-state scale override. 1.0 disables the feedback. |
pressedTranslate |
CoreOffset? |
How far the control moves while pressed. A shift rather than a shrink: a style whose whole idea is that a control sits above the page — a hard shadow cast down and to one side — says "pressed" by moving the control into that shadow, and scaling it instead reads as a different gesture entirely. Null leaves the control where it is, which is the default. Stated together with [pressedBoxShadow] it is the whole of the effect: the control travels the distance the shadow loses. |
pressedBoxShadow |
List<CoreShadowLayer>? |
The shadow the control casts while pressed. Null keeps the resting shadow, so a control that never states one is untouched. A style that raises its surfaces states the smaller stack here; the pair with [pressedTranslate] is what makes a press read as the surface going down rather than as it changing size. Deliberately without a
default*
, and it cannot have one: the null test decides whether the press-shadow exists at all. Flutter wraps the child in an
AnimatedContainer
carrying a
BoxDecoration
only when this is set, and Web emits the press-shadow custom property and its transition only then. A default would put that wrapper — and a shadow that overrides whatever the caller's own subtree draws — under every
Clickable
in the kit, which is most interactive components.
|
disabledOpacity |
double? |
Disabled-state opacity override. |
duration |
Duration? |
Container transition duration override. |
pressDuration |
Duration? |
Press-feedback transition duration override. |
동작 스펙 (Behavior)#
-
콘텐츠에 기본 탭 · 키보드 활성화 · hover · 포커스 피드백을 붙이는 합성 프리미티브입니다.
enabled: false는 주 활성화와 포커스 진입을 막습니다. 자체 선택 상태를 바꾸지는 않습니다. -
포커스된 표면의 Enter / Space는
onPressed를 실행합니다. Web은 자식 컨트롤에서 버블된 키 이벤트를 걸러 부모를 다시 활성화하지 않습니다. -
더블탭은 첫 클릭의
onPressed후 두 번째 클릭을onDoubleTap으로 처리합니다.enableFeedback: false는 누름 피드백을 끄며 시간은clickableStyle.pressDuration(Duration, ms), 스케일은 단위 없는 배율입니다. -
버튼 chrome까지 필요하면 Button, 목적지 링크이면 Link
입니다. 이미 구성한 임의 콘텐츠에 인터랙션만 붙일 때
Clickable을 씁니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
더 구체적인 역할을 가진 컴포넌트를 합성할 때는 semanticsButton: false
Clickable(
onPressed: toggle,
semanticsButton: false, // checkbox/switch/tab이 자기 role을 직접 붙임
child: myCheckboxVisual,
)
semanticsButton은 기본 true라 Clickable이 스스로를 버튼으로 알립니다. checkbox·switch·tab처럼 더 구체적인 역할을 가진 컴포넌트를 합성할 때 이를 끄지 않으면 실제 역할(checkbox 등)과 다른 "버튼"으로 잘못 읽힙니다.
❌ Don't#
아이콘만 담은 Clickable에 접근 가능한 이름 없이 두지 않기
// ❌ 이름 없는 버튼으로 읽힘 — child 가 텍스트를 렌더하지 않음
Clickable(onPressed: handleClose, child: Icon(LucideIcons.x))
// ✅ Web: attributes 로 aria-label / Flutter: 바깥에 Semantics 로 이름 부여
Clickable(
onPressed: handleClose,
attributes: {'aria-label': 'Close'}, // Web
child: Icon(LucideIcons.x),
)
Clickable은 자체 이름이 없고 child가 렌더한 텍스트가 곧 접근 가능한 이름이 됩니다. 아이콘만 있는 child는 스크린 리더에 "이름 없는 버튼"으로 읽히므로 호출자가 이름을 직접 붙여야 합니다.
접근성 (Accessibility)#
Clickable 은 자기 자신을 버튼으로 알립니다 — 위에 쌓이는 컴포넌트가 각자
기억하지 않아도 되도록, 역할과 활성/비활성 상태를 이 레이어에서 한 번 내보냅니다.
역할#
-
Flutter:
Semantics(enabled: enabled, button: semanticsButton, selected: semanticsSelected).semanticsButton은 기본true이고, 더 구체적인 역할을 가진 컴포넌트(checkbox / switch / tab 등)가false를 넘긴 뒤 자기 역할을 직접 붙입니다. 탭 활성화 시TapSemanticEvent도 함께 발생합니다. -
Web: 루트
<div>에role="button", 활성일 때tabindex="0", 비활성일 때aria-disabled="true",semanticsSelected가 있으면aria-selected.semanticsButton: false는role만 내려놓고tabindex는 남깁니다 — 더 구체적인 컨트롤도 여전히 포커스 대상입니다. 호출자attributes가 마지막에 병합되므로, 감싸는 컴포넌트가 넘긴role/tabindex이 기본값을 덮습니다.
키보드#
| 키 | 동작 |
|---|---|
Enter | 활성화 (onPressed 호출) |
Space | 활성화 (onPressed 호출) |
그 외 키는 처리하지 않습니다 — Escape·방향키·Home/End 모두 바인딩이 없습니다.
Web 은 keydown 이 자기 노드에서 발생했을 때만 활성화합니다. 안쪽에 포커스
가능한 컨트롤이 들어 있을 때 그 컨트롤의 키 입력이 버블링돼 이중 활성화되는 것을
막기 위한 것으로, native <button> 과 같은 동작입니다.
포커스 관리#
양 플랫폼 모두 포커스 가능하며, 비활성일 때 탭 순서에서 빠집니다.
-
Flutter:
FocusNode를 직접 소유하고(호출자가focusNode를 넘기면 그것을 사용),FocusableActionDetector(enabled:)가 비활성 시 traversal 에서 제거합니다. 포커스 링은FocusOutline로 그리며focusOutline: true가 기본값 —false로 끌 수 있습니다. -
Web:
tabindex="0"로 브라우저 native 포커스를 쓰고, 링은 CSSfocus-visible로 그립니다.focusin/focusout리스너는 호출자가onFocus를 넘겼을 때만 붙습니다.
포커스 트랩이나 포커스 복원은 어느 쪽에도 없습니다.
스크린 리더#
버튼으로, 그리고 활성/비활성 상태와 함께 읽힙니다. 이름은 child 가 렌더한
텍스트에서 옵니다. semanticsButton: false 로 버튼 역할을 내려놓고 자기 역할을 붙이는
것이 상위 컴포넌트가 역할을 갈아끼우는 공식 경로이며, 양 플랫폼 같은 파라미터입니다
(Web 은 호출자 role 속성으로 덮어쓰는 길도 남아 있습니다).
알려진 제약#
-
자체 이름이 없습니다.
child가 렌더한 텍스트가 곧 이름이므로, 아이콘만 담은Clickable은 이름 없는 버튼으로 읽힙니다. 이름은 호출자가 붙여야 합니다 — Web 은attributes로aria-label을, Flutter 는 바깥에 자신의Semantics를 두는 방식. -
Web 은
Space를 keydown 에서 활성화하고 key repeat 을 구분하지 않습니다. native<button>(keyup 활성화)과 달라, Space 를 누르고 있으면 반복 발생합니다. - 비활성일 때도 시맨틱 트리에는 남아 "비활성 버튼"으로 읽힙니다 — 트리에서 제거되는 것이 아니라 상태로 표시됩니다.
전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 활성화 | GestureDetector · ActivateIntent | click · dblclick · keydown / keyup |
| 누름 표시 | 상태 기반 Transform / 애니메이션 | CSS pseudo-class · 키보드 누름 DOM 마커 |
| 의미·선택 | Semantics(button:, selected:) |
role="button" / aria-selected |
| 외부 포커스 인프라 | FocusNode · WidgetStatesController | DOM 포커스 · caller attributes |
Web의 일부 세부 포인터·롱프레스 필드는 선언되어도 Flutter의 Gesture 콜백처럼 모두 전달되지는 않습니다. 공통 주 클릭·키보드 활성화와 세부 제스처의 배선 결손을 구분해야 합니다.