Buttons

버튼은 가장 기본적이면서도 중요한 상호작용 요소입니다. Kosmos DSL로 일관된 버튼 패턴을 설계·재사용하고, 접근성/비동기/취소·확인 UX까지 포함하는 실전 가이드를 제공합니다.

기본 버튼 컴포넌트

버튼은 a 또는 button으로 표현할 수 있습니다. 역할에 따라 올바른 태그를 선택하세요.

// 공통 버튼 (링크/버튼 겸용)
public class CompButton implements HtmlComponent {
  public enum Kind { PRIMARY, SECONDARY, SUCCESS, DANGER, WARNING, INFO, LIGHT, DARK, LINK, OUTLINE_PRIMARY }
  private final Kind kind;
  private final String label;
  private final String href;     // null 이면 <button type="button">
  private final String size;     // sm | md | lg | null
  private final boolean disabled;
  private final String icon;     // "bi bi-plus" 등 (optional)
  private final String ariaLabel;

  public CompButton(Kind kind, String label, String href, String size, boolean disabled, String icon, String ariaLabel) {
    this.kind = kind; this.label = label; this.href = href; this.size = size; this.disabled = disabled; this.icon = icon; this.ariaLabel = ariaLabel;
  }

  @Override public String render(RenderContext ctx) {
    var cls = new StringBuilder("btn ");
    switch (kind) {
      case OUTLINE_PRIMARY -> cls.append("btn-outline-primary");
      case LINK            -> cls.append("btn-link");
      case PRIMARY         -> cls.append("btn-primary");
      case SECONDARY       -> cls.append("btn-secondary");
      case SUCCESS         -> cls.append("btn-success");
      case DANGER          -> cls.append("btn-danger");
      case WARNING         -> cls.append("btn-warning");
      case INFO            -> cls.append("btn-info");
      case LIGHT           -> cls.append("btn-light");
      case DARK            -> cls.append("btn-dark");
    }
    if (size != null) cls.append(" btn-").append(size);     // btn-sm | btn-lg
    if (href != null) {
      var a = El.a().css(cls.toString()).href(href);
      if (ariaLabel != null) a.attr("aria-label", ariaLabel);
      if (icon != null) a.child(El.i().css(icon + " me-1").attr("aria-hidden","true"));
      if (disabled) a.attr("aria-disabled","true").attr("tabindex","-1").css("disabled");
      a.text(label);
      return a.render(ctx);
    } else {
      var b = El.button().css(cls.toString()).attr("type","button");
      if (ariaLabel != null) b.attr("aria-label", ariaLabel);
      if (icon != null) b.child(El.i().css(icon + " me-1").attr("aria-hidden","true"));
      if (disabled) b.attr("disabled","disabled").attr("aria-disabled","true");
      b.text(label);
      return b.render(ctx);
    }
  }
}

컨텍스트 매핑

의미 권장 클래스 비고
주 액션 btn btn-primary 페이지 당 1개 권장
보조 액션 btn btn-secondary 취소/뒤로 등
확정/성공 btn btn-success 완료/승인
위험/삭제 btn btn-danger 삭제/중단
주의 btn btn-warning 잠재적 위험
정보 btn btn-info 도움말/설명
아웃라인 btn btn-outline-* 밀도 높은 UI
링크 스타일 btn btn-link 텍스트 링크 느낌

변형: 아이콘/크기/블록

// 아이콘 + 소형
new CompButton(CompButton.Kind.SECONDARY, "추가", "#", "sm", false, "bi bi-plus", "항목 추가");

// 대형 + 블록(그리드로 구현)
El.div().css("d-grid gap-2").children(
  new CompButton(CompButton.Kind.PRIMARY, "제출", null, "lg", false, "bi bi-send", "제출"),
  new CompButton(CompButton.Kind.OUTLINE_PRIMARY, "미리보기", "#", "lg", false, "bi bi-eye", "미리보기")
);

버튼 그룹/툴바

// 그룹
public class CompButtonGroup implements HtmlComponent {
  private final List<HtmlComponent> buttons;
  public CompButtonGroup(List<HtmlComponent> buttons) { this.buttons = buttons; }
  @Override public String render(RenderContext ctx) {
    return El.div().css("btn-group").attr("role","group").children(buttons).render(ctx);
  }
}

// 사용
new CompButtonGroup(List.of(
  new CompButton(CompButton.Kind.SECONDARY, "이전", "#", null, false, "bi bi-chevron-left", "이전"),
  new CompButton(CompButton.Kind.PRIMARY, "다음", "#", null, false, "bi bi-chevron-right", "다음")
));

비동기/로딩 상태

중복 클릭 방지/로딩 UI를 제공하는 패턴입니다. 서버 렌더만으로도 UX를 크게 개선할 수 있습니다.

// 로딩 스피너를 포함한 비동기 버튼
public class CompAsyncButton implements HtmlComponent {
  private final String id; private final String label; private final String actionHref; private final String spinnerText;
  public CompAsyncButton(String id, String label, String actionHref, String spinnerText) {
    this.id = id; this.label = label; this.actionHref = actionHref; this.spinnerText = spinnerText;
  }
  @Override public String render(RenderContext ctx) {
    return El.button().css("btn btn-primary").attr("type","button").attr("id", id)
      .child(El.span().css("spinner-border spinner-border-sm me-2 d-none").attr("role","status").attr("aria-hidden","true"))
      .text(label)
      .attr("data-action-href", actionHref) // 프론트 스크립트가 fetch/submit 수행
      .render(ctx);
  }
}
// 최소 스크립트 (예시)
// 클릭 시 스피너 표시 & 중복 클릭 방지. 완료 후 라벨 복원.
document.addEventListener("click", (e) => {
  const b = e.target.closest("button[data-action-href]");
  if (!b) return;
  if (b.dataset.busy === "1") return;
  b.dataset.busy = "1";
  const spin = b.querySelector(".spinner-border");
  if (spin) spin.classList.remove("d-none");
  const label = b.textContent;
  b.setAttribute("aria-busy","true");
  fetch(b.dataset.actionHref, { method: "POST" })
    .finally(() => {
      b.dataset.busy = "0";
      b.setAttribute("aria-busy","false");
      if (spin) spin.classList.add("d-none");
      b.textContent = label;
    });
});

확인(Confirm) 패턴

// 삭제 확인 버튼 (모달 트리거)
public class CompDangerConfirmButton implements HtmlComponent {
  private final String label; private final String modalTargetId;
  public CompDangerConfirmButton(String label, String modalTargetId) {
    this.label = label; this.modalTargetId = modalTargetId;
  }
  @Override public String render(RenderContext ctx) {
    return El.button().css("btn btn-danger")
      .attr("type","button")
      .attr("data-bs-toggle","modal")
      .attr("data-bs-target","#" + modalTargetId)
      .child(El.i().css("bi bi-trash me-1").attr("aria-hidden","true"))
      .text(label)
      .render(ctx);
  }
}

버튼 해부도

.btn .btn-primary icon state

접근성 체크리스트

  • 역할/태그: 페이지 이동은 a, 상태 변화는 button.
  • 키보드 포커스: 탭 순서, Enter/Space 활성화 확인.
  • 토글 버튼: aria-pressed="true|false" 적용.
  • 비활성화: 링크 비활성은 aria-disabled + tabindex="-1", 버튼은 disabled.
  • 라벨: 아이콘만 있는 버튼은 aria-label 제공.

베스트 프랙티스

상황 권장 지양
반복 화면 공통 버튼을 Comp*로 캡슐화 페이지마다 CSS/마크업 복붙
중복 클릭 로딩 상태/비활성화 처리 서버 중복 처리만 의존
확인/취소 UX 확정 액션은 Danger+Confirm 모달 즉시 삭제/영구 작업
밀도 높은 UI btn-outline-*, btn-sm 강한 채도/대형 버튼의 남용

주의사항

Primary 남용: 한 화면에 Primary가 여러 개면 사용자가 결정을 내리기 어렵습니다. 우선순위를 정하세요.
보안: 삭제/위험 작업은 서버에서도 CSRF/권한 검증을 반드시 수행하세요.
번들 크기: 아이콘 폰트는 사용량을 점검하고 필요한 아이콘만 번들링하세요.

다음으로