FocusOutline | CoUI
본문으로 건너뛰기
LogoCoUI

FocusOutline

focused 일 때 child 둘레에 포커스 링을 그리는 순수 시각 래퍼 (인프라)

목차

focused 가 true 일 때 child 둘레에 포커스 링을 그리는 순수 시각 래퍼(인프라)입니다. 링만 그릴 뿐 포커스 추적은 호출자 책임입니다 — 올바른 "포커스" 정의가 호출 지점마다 다르기 때문(텍스트 필드는 _focusNode.hasFocus, 리스트 항목은 선택 인덱스 등). 입력·버튼 등이 내부적으로 사용합니다.

Live Preview#

사용법#

FocusOutline(
  focused: true, // 호출자가 추적한 포커스 상태
  child: Card(child: Text('포커스된 요소').bodyMedium),
)

빠른 오버라이드 (Chain)#

이미 만든 FocusOutline 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 == CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class FocusOutlineChainExample extends StatelessWidget {
  const FocusOutlineChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return FocusOutline(
          focused: true,
          child: Card(child: Text('포커스된 요소').bodyMedium),
        )
        .withStyle(
          const CoreFocusOutlineStyle(
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderWidth: CoreStrokeWidth.stroke3,
            duration: Duration(milliseconds: CoreDuration.fast),
          ),
        )
        .radius16;
  }
}
class FocusOutlineChainExample extends StatelessComponent {
  const FocusOutlineChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return FocusOutline(
          focused: true,
          child: Card(child: Text('포커스된 요소').bodyMedium),
        )
        .withStyle(
          const CoreFocusOutlineStyle(
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderWidth: CoreStrokeWidth.stroke3,
            duration: Duration(milliseconds: CoreDuration.fast),
          ),
        )
        .radius16;
  }
}

Props / Parameters#

W 는 Flutter의 Widget, Web의 Jaspr Component 입니다.

파라미터타입기본값설명
childW필수링으로 감쌀 콘텐츠
focused bool false 링 표시 여부 (호출자가 추적)
shape CoreFocusOutlineShape rectangle 링 모양 (rectangle / circle)
focusOutlineStyle CoreFocusOutlineStyle? null 링 색·두께·offset·radius 단일 진입점

스타일 시스템 (Style System)#

CoreFocusOutlineStyle 필드#

필드타입설명
align double? Distance from the child's edge to the ring's outer edge (logical px, pre-scaling).
borderRadius CoreBorderRadius? Border radius override (rectangle shape only). No defaultBorderRadius , deliberately — the right value belongs to whatever this ring is drawn around, so no constant can hold it. Hosts inject their own resolved radius into this slot ( Button passes focusOutlineStyle?.borderRadius ?? <its own resolved radius> ; the subfocus infra passes its focus-ring radius), and a host that wants square ring corners simply leaves it null: Flutter's ring-radius helper returns .zero on null, and Web spreads 'border-radius': ?radius so the rectangle arm emits no property at all. A constant would round every focus ring in the kit — including the ones deliberately drawn around square children — and would be the wrong number for each host besides (a button's corner differs per size, radius6 to radius16 ; cards are radius16 ). Contrast [circleBorderRadius], which does have a default: the circle shape has one correct radius (full pill) regardless of host. @hostDerived tells the Figma reader this is "value comes from the host", not "nothing is drawn" — otherwise it exports the field as an absence and the canvas is asked to show square corners.
circleBorderRadius CoreBorderRadius? Border radius override applied when the focus outline shape is circle. Defaults to [defaultCircleBorderRadius] (all-9999 token) so the ring follows a full pill; designers can override to tune the circular ring curvature.
borderColorCoreColor?Ring colour override.
borderWidth double? Ring width override (logical px, pre-scaling).
offsetColor CoreColor? Colour of the gap band between the child's edge and the ring's inner edge. null → [defaultOffsetColor] (transparent: the gap shows whatever is behind the child). The band is painted only outside the child, never behind it, so a translucent child body does not show it through. A host whose background is unknown states CoreFocusRing.opaqueOffsetColor here to turn the band on.
duration Duration? Animation duration for the focus ring fade transition.
ringOpacity double? Focus-ring opacity multiplier override. null → [defaultRingOpacity]. Applied by FocusOutline 's own resolver via CoreColor.scaleAlpha on top of whichever borderColor is in effect (default or per-instance override) — setting ringOpacity alone, without also touching borderColor , dims the ring on both platforms.

동작 스펙 (Behavior)#

  • focused 값으로 링의 시각적 표시만 제어합니다. 스스로 포커스를 감지·이동·가두거나 child를 활성화하지 않습니다. 호출부가 키보드 포커스 하이라이트를 전달해야 합니다.
  • shape 와 스타일의 반경·offset·borderWidth로 child 바깥 링을 구성합니다. 치수 단위는 논리 px, duration은 ms입니다. 표시 전환은 기하가 커지는 애니메이션이 아니라 불투명도 전환입니다.
  • 인터랙션과 포커스 표시를 함께 맡기려면 Clickable, 버튼 chrome도 필요하면 Button 입니다. 이미 포커스를 관리하는 컨트롤의 링만 그릴 때 이 래퍼를 씁니다.

사용 가이드라인 (Usage Guidelines)#

✅ Do#

호출자가 추적한 실제 포커스 상태를 focused에 전달

FocusOutline(
  focused: focusNode.hasFocus,
  shape: CoreFocusOutlineShape.circle,
  child: Avatar(initials: 'JD'),
)

FocusOutline은 자체 FocusNode를 갖지 않는 순수 시각 레이어입니다 — 링이 정확한 순간에 나타나려면 child를 실제로 포커스 가능하게 만드는 위젯의 포커스 상태를 그대로 넘겨야 합니다. 원형 콘텐츠에는 shape: circle을 지정해 링이 사각 모서리로 어색하게 그려지지 않게 합니다.


❌ Don't#

focused 값만으로 child가 키보드 포커스를 받는다고 가정하지 않기

// ❌ Container에는 Focus/FocusNode가 없어 Tab으로 도달할 수 없음
FocusOutline(
  focused: isActive,
  child: Container(color: Colors.blue),
)

FocusOutline은 IgnorePointer로 감싸인 장식 레이어일 뿐이라 hit-test 도, 포커스 요청도 하지 않습니다. child 자체가 Focus / FocusableActionDetector / 네이티브 버튼 등으로 실제 포커스를 받을 수 있어야 링이 의미를 가집니다.

접근성 (Accessibility)#

역할#

양 플랫폼 모두 아무 role 도 emit 하지 않습니다. Flutter 는 링을 Stacks / Position / IgnorePointer / AnimatedOpacity 로 쌓은 장식용 박스라 시맨틱 노드를 만들지 않고, Web 은 role / aria-* 가 없는 <div> 하나입니다. FocusOutline 은 순수 시각 레이어이며 접근성 트리에는 존재하지 않습니다.

키보드#

처리하는 키가 없습니다. 양 플랫폼 모두 키 핸들러를 등록하지 않습니다.

포커스 관리#

이 컴포넌트는 포커스를 감지·요청·보유·트랩하지 않습니다. 이름과 달리 포커스에 관여하는 코드가 없습니다 — focused 는 기본값 false 인 평범한 호출자 제공 bool 이고, 링은 그 값으로 계산된 그림자·장식 레이어일 뿐입니다.

  • Flutter: FocusNode / Focus / FocusScope / FocusableActionDetector 가 없습니다. 링 레이어는 IgnorePointer 로 감싸여 hit-test 를 가져가지 않습니다.
  • Web: tabindex 가 없고 :focus / :focus-visible CSS 선택자도 없습니다. 링이 브라우저 네이티브 포커스에 반응하지 않습니다.

child 를 포커스 가능하게 만드는 것도, 포커스 상태를 추적해 focused 를 갱신하는 것도 전적으로 호출자 책임입니다.

스크린 리더#

아무것도 추가하지 않습니다. child 는 이 래퍼가 없을 때와 정확히 동일하게 읽히며, 화면에 보이는 링에 대응하는 보조기술 신호는 양 플랫폼 모두 없습니다.

알려진 제약#

  • 호출자가 focused: true 로 다시 렌더하지 않으면 링은 나타나지 않습니다. Web 은 :focus-visible 규칙이 없어 네이티브 포커스를 스스로 따라가지 못합니다.
  • 키보드 포커스와 포인터 포커스를 구분하지 못합니다 — 그 판단은 호출자의 bool 안에만 존재합니다. :focus-visible 에 해당하는 동작이 필요하면 호출자가 직접 구현해야 합니다.
  • Flutter 의 링은 Stacks 오버레이로 child 바깥에 그려지며 레이아웃 공간을 예약하지 않으므로, 인접 콘텐츠와 겹칠 수 있습니다.

포커스 표시가 강제 색상 모드에서 어떻게 다뤄지는지 등 전 컴포넌트 공통 사항은 전역 접근성 축을 참고하세요.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
바깥 링 pointer를 무시하는 Stacks 장식층 (Border.all 스트로크) child 래퍼의 CSS outline (outline-offset = 간격)
간격 밴드 켠 경우에만 child 바깥 스트로크 — child 뒤를 채우지 않음 켠 경우에만 box-shadow 한 겹
전환AnimatedOpacityCSS transition
레이아웃child 크기를 유지하는 passthrough정상 흐름의 block <div>
의미·포커스child의 의미·포커스 유지자체 role / tabindex 없이 child의 의미 유지