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/권한 검증을 반드시 수행하세요.
번들 크기: 아이콘 폰트는 사용량을 점검하고 필요한 아이콘만 번들링하세요.
다음으로
- Modal & Offcanvas — 확인/취소 UX와 패턴 연계
- Form Validation — 에러 표시와 버튼 상태 관리
- Page Template & Slots — 액션 영역 배치 가이드