Input Types

텍스트·숫자·이메일·전화·날짜·선택·체크·라디오·텍스트영역 등 다양한 입력 컨트롤을 Kosmos DSL로 일관된 API접근성을 갖추어 구성합니다.

설계 원칙

  • 의미 기반 타입: 가능한 한 올바른 type을 사용하여 모바일 키보드/자체 밸리데이션을 활용합니다.
  • 레이블·힌트·에러 표준화: label, .form-text, .invalid-feedback를 통일합니다.
  • 값 포맷과 검증 분리: 마스킹/포맷은 보조일 뿐, 최종 검증은 서버가 담당합니다.
  • 접근성 일급: forid, aria-describedby, aria-invalid 등을 지킵니다.

코드 심화: 공용 Input 유틸

public final class FormInputs {
  private FormInputs() {}

  public static HtmlComponent text(String id, String value, String placeholder) {
    return El.input().css("form-control").attr("type","text")
      .attr("id", id).attr("name", id)
      .attr("placeholder", placeholder == null ? "" : placeholder)
      .attr("value", value == null ? "" : value);
  }

  public static HtmlComponent email(String id, String value) {
    return El.input().css("form-control").attr("type","email")
      .attr("id", id).attr("name", id).attr("autocomplete","email")
      .attr("inputmode","email").attr("value", value == null ? "" : value);
  }

  public static HtmlComponent tel(String id, String value) {
    return El.input().css("form-control").attr("type","tel")
      .attr("id", id).attr("name", id).attr("inputmode","tel")
      .attr("value", value == null ? "" : value);
  }

  public static HtmlComponent number(String id, Number value, Integer min, Integer max, Integer step) {
    var i = El.input().css("form-control").attr("type","number")
      .attr("id", id).attr("name", id);
    if (value != null) i.attr("value", String.valueOf(value));
    if (min != null)   i.attr("min", String.valueOf(min));
    if (max != null)   i.attr("max", String.valueOf(max));
    if (step != null)  i.attr("step", String.valueOf(step));
    return i;
  }

  public static HtmlComponent date(String id, String value) {
    return El.input().css("form-control").attr("type","date")
      .attr("id", id).attr("name", id).attr("value", value == null ? "" : value);
  }

  public static HtmlComponent select(String id, String current, List<Option> options, boolean disabled) {
    var s = El.select().css("form-select").attr("id", id).attr("name", id);
    if (disabled) s.attr("disabled","disabled");
    for (var o : options) {
      var el = El.option().attr("value", o.value()).text(o.label());
      if (Objects.equals(o.value(), current)) el.attr("selected","selected");
      s.child(el);
    }
    return s;
  }

  public static HtmlComponent textarea(String id, String value, int rows) {
    return El.textarea().css("form-control").attr("id", id).attr("name", id)
      .attr("rows", String.valueOf(Math.max(rows,3))).text(value == null ? "" : value);
  }

  public record Option(String value, String label) {}
}

텍스트 / 이메일 / 전화

// 텍스트
new CompFormGroup("name", "이름",
  FormInputs.text("name", dto.getName(), "예: 홍길동"),
  "실명 기준으로 입력하세요.", null
);

// 이메일
new CompFormGroup("email", "이메일",
  FormInputs.email("email", dto.getEmail()),
  "비밀번호 찾기 등 안내 발송", null
);

// 전화
new CompFormGroup("phone", "전화번호",
  FormInputs.tel("phone", dto.getPhone()),
  "숫자만 입력(예: 01012345678)", null
);

숫자 / 날짜

// 숫자
new CompFormGroup("age", "나이",
  FormInputs.number("age", dto.getAge(), 0, 120, 1),
  "0~120 사이 정수", null
);

// 날짜
new CompFormGroup("birth", "생년월일",
  FormInputs.date("birth", dto.getBirth()),
  "YYYY-MM-DD", null
);

선택 (Select)

List<FormInputs.Option> roles = List.of(
  new FormInputs.Option("", "역할 선택"),
  new FormInputs.Option("USER", "일반 사용자"),
  new FormInputs.Option("ADMIN", "관리자")
);

new CompFormGroup("role", "역할",
  FormInputs.select("role", dto.getRole(), roles, false),
  "권한에 따라 메뉴가 달라집니다.", null
);

체크박스 / 라디오

// 체크박스 (단일)
El.div().css("mb-3").children(
  El.div().css("form-check").children(
    El.input().css("form-check-input").attr("type","checkbox").attr("id","agree").attr("name","agree")
      .attr("value","Y").attr(dto.isAgree() ? "checked" : null, dto.isAgree() ? "checked" : null),
    El.label().css("form-check-label").attr("for","agree").text("약관에 동의합니다")
  ),
  El.div().css("form-text").text("동의해야 진행됩니다.")
);

// 라디오 (그룹)
El.div().css("mb-3").children(
  El.span().css("form-label d-block").text("알림 수신"),
  El.div().css("form-check").children(
    El.input().css("form-check-input").attr("type","radio").attr("name","notify").attr("id","notifyY").attr("value","Y")
      .attr(dto.getNotify().equals("Y") ? "checked" : null, dto.getNotify().equals("Y") ? "checked" : null),
    El.label().css("form-check-label").attr("for","notifyY").text("예")
  ),
  El.div().css("form-check").children(
    El.input().css("form-check-input").attr("type","radio").attr("name","notify").attr("id","notifyN").attr("value","N")
      .attr(dto.getNotify().equals("N") ? "checked" : null, dto.getNotify().equals("N") ? "checked" : null),
    El.label().css("form-check-label").attr("for","notifyN").text("아니오")
  )
);

텍스트 영역 (Textarea)

new CompFormGroup("bio", "소개",
  FormInputs.textarea("bio", dto.getBio(), 5),
  "간단한 자기소개를 작성하세요. 최대 500자.", null
);

상태/검증 클래스 매핑

상태 컨트롤 클래스 피드백 영역 적용 시점
기본 form-control 또는 form-select .form-text 항상
유효 is-valid .valid-feedback 선택적
오류 is-invalid .invalid-feedback 서버 검증 실패 시
비활성 disabled/readonly 제어 불가/보기 전용

형식 가이드: 포맷/마스킹

  • 전화/숫자 포맷: 입력 도움은 UX 보조이며, 서버에서는 순수 숫자로 검증/저장하세요.
  • 날짜: type="date"로 기본 피커를 우선 사용하고, 브라우저 미지원 시 폴리필(선택)을 고려합니다.
  • 이메일: type="email"은 기본 문법만 확인합니다. 도메인 검증 등은 서버에서 처리합니다.

접근성 체크리스트

  • 레이블 연결: label[for]input[id] 일치
  • 도움말/에러 연결: aria-describedby="idHelp idError"처럼 다중 연결 가능
  • 그룹 레이블: 라디오/체크 그룹에는 시각적 그룹 제목을 제공합니다.
  • 키보드 내비게이션: 라디오는 ↑/↓, 체크는 Space 동작을 점검합니다.

보안 & 서버 팁

주제 권장 지양
검증 서버 단에서 Bean Validation + 도메인 검증 클라이언트 검증만 신뢰
XSS 출력 이스케이프 / 화이트리스트 사용자 HTML을 그대로 출력
화이트리스트 셀렉트 옵션은 서버에서 승인된 값만 임의 문자열을 그대로 저장

샘플: 에러 상태 주입

@SuppressWarnings("unchecked")
Map<String,String> errs = (Map<String,String>) ctx.model("errors");

HtmlComponent email = FormInputs.email("email", form.getEmail())
  .css(errs != null && errs.containsKey("email") ? " is-invalid" : "");

El.div().css("mb-3").children(
  El.label().css("form-label").attr("for","email").text("이메일"),
  email,
  errs != null && errs.get("email") != null
    ? El.div().css("invalid-feedback d-block").text(errs.get("email"))
    : El.span().css("d-none").text("")
);

주의사항

placeholder 남용 금지: 레이블을 대체하지 않습니다. 레이블은 항상 필요합니다.
서버 검증 누락: 숫자/이메일/전화 형식은 최종적으로 서버에서 재검증해야 합니다.
옵션 동기화: <select>의 옵션은 서버 권한/설정과 반드시 동기화하세요.

다음으로

  • File Upload — 업로드 컨트롤/보안/검증
  • Validation — UI 상태/에러 요약/포커스
  • Alerts — 성공/오류 피드백 패턴
  • Buttons — 제출/취소/비동기 버튼 UX