Clickable#
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),
);
}
}
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;
}
}
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;
}
}
사용법#
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#
콘텐츠 · 상태#
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child |
Widget / Component |
필수 | 인터랙티브하게 만들 콘텐츠 |
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"
속성을 내려놓습니다 — 포커스 가능성은 양쪽 모두 그대로입니다
|
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. |
사용 가이드라인 (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 은 자기 자신을 버튼으로 알립니다 — 위에 쌓이는 컴포넌트가 각자
기억하지 않아도 되도록, 역할과 활성/비활성 상태를 이 레이어에서 한 번 내보냅니다.
역할 (Semantics)#
-
Flutter:
Semantics(enabled: enabled, button: semanticsButton).semanticsButton은 기본true이고, 더 구체적인 역할을 가진 컴포넌트(checkbox / switch / tab 등)가false를 넘긴 뒤 자기 역할을 직접 붙입니다. 탭 활성화 시TapSemanticEvent도 함께 발생합니다. -
Web: 루트
<div>에role="button", 활성일 때tabindex="0", 비활성일 때aria-disabled="true".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 를 누르고 있으면 반복 발생합니다. - 비활성일 때도 시맨틱 트리에는 남아 "비활성 버튼"으로 읽힙니다 — 트리에서 제거되는 것이 아니라 상태로 표시됩니다.
전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.