Clickable | CoUI
LogoCoUI

Clickable

임의의 child 를 인터랙티브하게 만드는 크로스플랫폼 클릭 래퍼 (인프라)

Clickable#

child 를 감싸 인터랙티브하게 만드는 인프라 프리미티브입니다 — 포인터 커서, 포커스 링, press 피드백 스케일, 키보드 활성화(Enter/Space)를 직접 소유합니다. Button 같은 상위 컴포넌트가 이 위에 쌓입니다. 직접 쓸 일은 드물지만, 커스텀 클릭 영역을 만들 때 raw GestureDetector 대신 사용하면 양 플랫폼 동작이 통일됩니다.

Live Preview#

사용법#

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은 기본 trueClickable이 스스로를 버튼으로 알립니다. 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: falserole 만 내려놓고 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 포커스를 쓰고, 링은 CSS focus-visible 로 그립니다. focusin / focusout 리스너는 호출자가 onFocus 를 넘겼을 때만 붙습니다.

포커스 트랩이나 포커스 복원은 어느 쪽에도 없습니다.

스크린 리더#

버튼으로, 그리고 활성/비활성 상태와 함께 읽힙니다. 이름은 child 가 렌더한 텍스트에서 옵니다. semanticsButton: false 로 버튼 역할을 내려놓고 자기 역할을 붙이는 것이 상위 컴포넌트가 역할을 갈아끼우는 공식 경로이며, 양 플랫폼 같은 파라미터입니다 (Web 은 호출자 role 속성으로 덮어쓰는 길도 남아 있습니다).

알려진 제약#

  • 자체 이름이 없습니다. child 가 렌더한 텍스트가 곧 이름이므로, 아이콘만 담은 Clickable이름 없는 버튼으로 읽힙니다. 이름은 호출자가 붙여야 합니다 — Web 은 attributesaria-label 을, Flutter 는 바깥에 자신의 Semantics 를 두는 방식.
  • Web 은 Spacekeydown 에서 활성화하고 key repeat 을 구분하지 않습니다. native <button>(keyup 활성화)과 달라, Space 를 누르고 있으면 반복 발생합니다.
  • 비활성일 때도 시맨틱 트리에는 남아 "비활성 버튼"으로 읽힙니다 — 트리에서 제거되는 것이 아니라 상태로 표시됩니다.

전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.