ข้ามไปเนื้อหาหลัก

Category: guide

HTML Forms & Native Validation

Native HTML5 form validation, Constraint Validation API, custom error messages, และ patterns สำหรับ accessible forms

· อ่านประมาณ 3 นาที

สารบัญ

HTML5 Validation Attributes

<!-- required — ต้องมีค่า -->
<input type="text" name="name" required />

<!-- type — validate format อัตโนมัติ -->
<input type="email" name="email" />    <!-- ต้องมี @ -->
<input type="url" name="url" />        <!-- ต้องขึ้นต้นด้วย http:// -->
<input type="number" name="age" />     <!-- ตัวเลขเท่านั้น -->
<input type="tel" name="phone" />      <!-- keyboard เปลี่ยน, ไม่ validate format -->

<!-- min / max -->
<input type="number" name="qty" min="1" max="100" />
<input type="date" name="birthday" max="2024-12-31" />
<input type="range" name="rating" min="1" max="5" step="1" />

<!-- minlength / maxlength -->
<input type="text" name="username" minlength="3" maxlength="20" />
<textarea name="bio" minlength="10" maxlength="500"></textarea>

<!-- pattern — regex -->
<input type="text" name="zip" pattern="[0-9]{5}" title="รหัสไปรษณีย์ 5 หลัก" />
<input type="password" name="pw"
  pattern="(?=.*[A-Z])(?=.*[0-9]).{8,}"
  title="ต้องมีตัวพิมพ์ใหญ่ ตัวเลข และยาวอย่างน้อย 8 ตัว" />

Form Element Types

<form id="register" novalidate>  <!-- novalidate: ปิด browser UI แต่ API ยังทำงาน -->

  <!-- Text inputs -->
  <input type="text" name="name" autocomplete="name" />
  <input type="email" name="email" autocomplete="email" />
  <input type="password" name="password" autocomplete="new-password" />

  <!-- Select -->
  <select name="role" required>
    <option value="">-- เลือก --</option>
    <option value="admin">Admin</option>
    <option value="user">User</option>
  </select>

  <!-- Checkboxes -->
  <input type="checkbox" name="agree" id="agree" required />
  <label for="agree">ยอมรับเงื่อนไข</label>

  <!-- Radio group -->
  <fieldset>
    <legend>ประเภทบัญชี</legend>
    <input type="radio" name="type" value="personal" id="personal" required />
    <label for="personal">Personal</label>
    <input type="radio" name="type" value="business" id="business" />
    <label for="business">Business</label>
  </fieldset>

  <button type="submit">สมัครสมาชิก</button>
</form>

Constraint Validation API

const input = document.querySelector<HTMLInputElement>('input[name="email"]')!;

// Check validity
input.validity.valid        // ถูกต้องทุก constraint หรือไม่
input.validity.valueMissing  // required แต่ว่าง
input.validity.typeMismatch  // type ไม่ตรง (email, url)
input.validity.patternMismatch  // pattern ไม่ตรง
input.validity.tooShort      // น้อยกว่า minlength
input.validity.tooLong       // มากกว่า maxlength
input.validity.rangeUnderflow  // น้อยกว่า min
input.validity.rangeOverflow   // มากกว่า max

// Error message จาก browser
input.validationMessage  // 'Please fill out this field.'

// Custom error message
input.setCustomValidity('Email นี้ถูกใช้งานแล้ว');
input.reportValidity();  // แสดง error bubble

// ล้าง custom error
input.setCustomValidity('');

// Check element
input.checkValidity();  // boolean, ไม่แสดง UI

// Check form ทั้งหมด
const form = document.querySelector<HTMLFormElement>('form')!;
form.checkValidity();  // boolean
form.reportValidity(); // boolean + แสดง UI

Custom Validation UI

const form = document.querySelector<HTMLFormElement>('#register')!;

form.addEventListener('submit', (event) => {
  event.preventDefault();

  if (!form.checkValidity()) {
    // แสดง error สำหรับทุก invalid fields
    const invalidFields = form.querySelectorAll<HTMLInputElement>(':invalid');
    invalidFields.forEach((field) => showError(field));
    invalidFields[0].focus();
    return;
  }

  // submit...
});

// Validate ทุก blur
form.addEventListener('focusout', (event) => {
  const field = event.target as HTMLInputElement;
  if (field.tagName === 'INPUT' || field.tagName === 'SELECT') {
    if (!field.validity.valid) showError(field);
    else clearError(field);
  }
});

function showError(field: HTMLInputElement) {
  const errorEl = field.parentElement?.querySelector('.field-error');
  if (errorEl) {
    errorEl.textContent = getErrorMessage(field);
    errorEl.removeAttribute('hidden');
  }
  field.setAttribute('aria-invalid', 'true');
}

function clearError(field: HTMLInputElement) {
  const errorEl = field.parentElement?.querySelector('.field-error');
  if (errorEl) errorEl.setAttribute('hidden', '');
  field.removeAttribute('aria-invalid');
}

function getErrorMessage(field: HTMLInputElement): string {
  if (field.validity.valueMissing) return 'กรุณากรอกข้อมูลนี้';
  if (field.validity.typeMismatch) {
    if (field.type === 'email') return 'รูปแบบอีเมลไม่ถูกต้อง';
    if (field.type === 'url') return 'รูปแบบ URL ไม่ถูกต้อง';
  }
  if (field.validity.tooShort) return `ต้องมีอย่างน้อย ${field.minLength} ตัวอักษร`;
  if (field.validity.patternMismatch) return field.title || 'รูปแบบไม่ถูกต้อง';
  return field.validationMessage;
}

Accessible Form Pattern

<!-- ✓ Label ผูกกับ input ด้วย for/id เสมอ -->
<div class="field">
  <label for="email" class="field-label">
    อีเมล
    <span class="required-mark" aria-hidden="true">*</span>
  </label>
  <input
    type="email"
    id="email"
    name="email"
    required
    autocomplete="email"
    aria-describedby="email-hint email-error"
    aria-required="true"
  />
  <p id="email-hint" class="field-hint">เราจะส่ง OTP ไปยังอีเมลนี้</p>
  <p id="email-error" class="field-error" hidden aria-live="polite"></p>
</div>
/* Visual invalid state */
input:invalid:not(:placeholder-shown) {
  border-color: #ef4444;
  background: rgba(239, 68, 68, 0.03);
}

/* ✓ ใช้ :user-invalid แทน :invalid เพื่อ avoid flash ก่อน user touch */
input:user-invalid {
  border-color: #ef4444;
}

/* Focus state ต้อง visible */
input:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

Server-side Validation Match

// ✓ Client validation = UX ปรับปรุง
// ✓ Server validation = Security ต้องมีเสมอ

// Pattern: validate client + show server errors
async function handleSubmit(formData: FormData) {
  const res = await fetch('/api/register', {
    method: 'POST',
    body: formData,
  });

  if (!res.ok) {
    const { errors } = await res.json();
    // errors = { email: 'อีเมลนี้ถูกใช้งานแล้ว', ... }

    for (const [field, message] of Object.entries(errors)) {
      const input = form.querySelector<HTMLInputElement>(`[name="${field}"]`);
      if (input) {
        input.setCustomValidity(message as string);
        showError(input);
        // ล้าง custom validity เมื่อ user แก้ไข
        input.addEventListener('input', () => {
          input.setCustomValidity('');
          clearError(input);
        }, { once: true });
      }
    }
  }
}