Skip to content

高级表单特性与交互技巧

表单验证

1. 内置验证属性

html
<form id="registration">
  <!-- 必填字段 -->
  <input type="text" required />

  <!-- 模式匹配 -->
  <input type="text" pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}" title="请输入格式为XXX-XXX-XXXX的电话号码" />

  <!-- 长度限制 -->
  <input type="text" minlength="6" maxlength="12" />

  <!-- 数值范围 -->
  <input type="number" min="0" max="100" step="5" />

  <!-- 自定义错误消息 -->
  <input type="email" oninvalid="this.setCustomValidity('请输入有效的邮箱地址')" oninput="this.setCustomValidity('')" />
</form>

2. Constraint Validation API

javascript
const form = document.getElementById('registration')
const email = form.querySelector('input[type="email"]')

// 检查表单字段的有效性
function validateField(field) {
  if (field.validity.valueMissing) {
    field.setCustomValidity('此字段不能为空')
  } else if (field.validity.typeMismatch) {
    field.setCustomValidity('请输入正确的格式')
  } else if (field.validity.tooShort) {
    field.setCustomValidity(`最少需要${field.minLength}个字符`)
  } else {
    field.setCustomValidity('')
  }
}

// 表单提交验证
form.addEventListener('submit', (event) => {
  if (!form.checkValidity()) {
    event.preventDefault()
    Array.from(form.elements).forEach(validateField)
  }
})

// 实时验证
email.addEventListener('input', () => {
  validateField(email)
})

自定义表单控件

1. 自定义下拉选择器

html
<div class="custom-select" role="combobox" aria-expanded="false" aria-haspopup="listbox">
  <button class="select-button" aria-label="选择选项">
    <span class="selected-value">请选择</span>
    <span class="arrow"></span>
  </button>
  <ul class="options-list" role="listbox">
    <li role="option" data-value="1">选项 1</li>
    <li role="option" data-value="2">选项 2</li>
    <li role="option" data-value="3">选项 3</li>
  </ul>
</div>

<style>
  .custom-select {
    position: relative;
    width: 200px;
  }

  .select-button {
    width: 100%;
    padding: 8px;
    border: 1px solid #ccc;
    background: white;
    cursor: pointer;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .options-list {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    background: white;
    border: 1px solid #ccc;
    border-top: none;
    max-height: 200px;
    overflow-y: auto;
    display: none;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .custom-select[aria-expanded='true'] .options-list {
    display: block;
  }

  .options-list li {
    padding: 8px;
    cursor: pointer;
  }

  .options-list li:hover {
    background: #f5f5f5;
  }
</style>

<script>
  class CustomSelect {
    constructor(element) {
      this.element = element
      this.button = element.querySelector('.select-button')
      this.optionsList = element.querySelector('.options-list')
      this.selectedValue = element.querySelector('.selected-value')

      this.setupEventListeners()
    }

    setupEventListeners() {
      this.button.addEventListener('click', () => this.toggleDropdown())

      this.optionsList.addEventListener('click', (e) => {
        if (e.target.hasAttribute('data-value')) {
          this.selectOption(e.target)
        }
      })

      // 键盘导航
      this.element.addEventListener('keydown', (e) => {
        switch (e.key) {
          case 'Enter':
            this.toggleDropdown()
            break
          case 'Escape':
            this.closeDropdown()
            break
          case 'ArrowDown':
            e.preventDefault()
            this.navigateOptions(1)
            break
          case 'ArrowUp':
            e.preventDefault()
            this.navigateOptions(-1)
            break
        }
      })
    }

    toggleDropdown() {
      const isExpanded = this.element.getAttribute('aria-expanded') === 'true'
      this.element.setAttribute('aria-expanded', !isExpanded)
    }

    closeDropdown() {
      this.element.setAttribute('aria-expanded', 'false')
    }

    selectOption(option) {
      this.selectedValue.textContent = option.textContent
      this.element.dispatchEvent(
        new CustomEvent('change', {
          detail: { value: option.dataset.value }
        })
      )
      this.closeDropdown()
    }

    navigateOptions(direction) {
      const options = Array.from(this.optionsList.children)
      const currentIndex = options.findIndex((option) => option.classList.contains('focused'))

      let nextIndex = currentIndex + direction
      if (nextIndex < 0) nextIndex = options.length - 1
      if (nextIndex >= options.length) nextIndex = 0

      options.forEach((option) => option.classList.remove('focused'))
      options[nextIndex].classList.add('focused')
      options[nextIndex].scrollIntoView({ block: 'nearest' })
    }
  }

  // 初始化所有自定义选择器
  document.querySelectorAll('.custom-select').forEach((select) => {
    new CustomSelect(select)
  })
</script>

2. 自定义文件上传

html
<div class="custom-file-upload">
  <input type="file" id="file-input" multiple accept="image/*" class="hidden" />
  <label for="file-input" class="upload-trigger">
    <span class="icon">📁</span>
    <span class="text">点击或拖拽文件上传</span>
  </label>
  <div class="preview-container"></div>
</div>

<style>
  .custom-file-upload {
    border: 2px dashed #ccc;
    padding: 20px;
    text-align: center;
    transition: border-color 0.3s;
  }

  .custom-file-upload.dragover {
    border-color: #42b983;
  }

  .hidden {
    display: none;
  }

  .upload-trigger {
    display: flex;
    flex-direction: column;
    align-items: center;
    cursor: pointer;
  }

  .preview-container {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
    gap: 10px;
    margin-top: 20px;
  }

  .preview-item {
    position: relative;
    padding-top: 100%;
  }

  .preview-item img {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    border-radius: 4px;
  }
</style>

<script>
  class CustomFileUpload {
    constructor(element) {
      this.element = element
      this.input = element.querySelector('input[type="file"]')
      this.previewContainer = element.querySelector('.preview-container')

      this.setupEventListeners()
    }

    setupEventListeners() {
      // 文件选择
      this.input.addEventListener('change', () => {
        this.handleFiles(this.input.files)
      })

      // 拖拽上传
      this.element.addEventListener('dragover', (e) => {
        e.preventDefault()
        this.element.classList.add('dragover')
      })

      this.element.addEventListener('dragleave', () => {
        this.element.classList.remove('dragover')
      })

      this.element.addEventListener('drop', (e) => {
        e.preventDefault()
        this.element.classList.remove('dragover')
        this.handleFiles(e.dataTransfer.files)
      })
    }

    handleFiles(files) {
      Array.from(files).forEach((file) => {
        if (!file.type.startsWith('image/')) return

        const reader = new FileReader()
        reader.onload = (e) => this.createPreview(e.target.result)
        reader.readAsDataURL(file)
      })
    }

    createPreview(url) {
      const preview = document.createElement('div')
      preview.className = 'preview-item'
      preview.innerHTML = `<img src="${url}" alt="预览图">`
      this.previewContainer.appendChild(preview)
    }
  }

  // 初始化所有自定义文件上传
  document.querySelectorAll('.custom-file-upload').forEach((upload) => {
    new CustomFileUpload(upload)
  })
</script>

表单无障碍性

1. ARIA属性应用

html
<form role="form" aria-label="注册表单">
  <div role="group" aria-labelledby="personal-info">
    <h3 id="personal-info">个人信息</h3>

    <label for="name">姓名</label>
    <input type="text" id="name" aria-required="true" aria-invalid="false" />
    <span role="alert" aria-live="polite"></span>

    <label for="gender">性别</label>
    <div role="radiogroup" aria-labelledby="gender">
      <input type="radio" id="male" name="gender" value="male" />
      <label for="male">男</label>
      <input type="radio" id="female" name="gender" value="female" />
      <label for="female">女</label>
    </div>
  </div>
</form>

2. 键盘导航优化

javascript
class KeyboardNavigation {
  constructor(form) {
    this.form = form
    this.focusableElements = this.getFocusableElements()
    this.setupEventListeners()
  }

  getFocusableElements() {
    return Array.from(this.form.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
  }

  setupEventListeners() {
    this.form.addEventListener('keydown', (e) => {
      if (e.key === 'Tab') {
        this.handleTabNavigation(e)
      }
    })
  }

  handleTabNavigation(event) {
    const currentIndex = this.focusableElements.indexOf(document.activeElement)

    if (event.shiftKey) {
      // 向后导航
      if (currentIndex === 0) {
        event.preventDefault()
        this.focusableElements[this.focusableElements.length - 1].focus()
      }
    } else {
      // 向前导航
      if (currentIndex === this.focusableElements.length - 1) {
        event.preventDefault()
        this.focusableElements[0].focus()
      }
    }
  }
}

// 初始化键盘导航
const form = document.querySelector('form')
new KeyboardNavigation(form)

动态表单处理

1. 条件字段显示

javascript
class DynamicForm {
  constructor(form) {
    this.form = form
    this.setupConditionFields()
  }

  setupConditionFields() {
    const triggers = this.form.querySelectorAll('[data-show-target]')

    triggers.forEach((trigger) => {
      trigger.addEventListener('change', () => {
        const targetId = trigger.dataset.showTarget
        const targetField = document.getElementById(targetId)

        if (trigger.checked) {
          targetField.style.display = 'block'
          this.enableFields(targetField)
        } else {
          targetField.style.display = 'none'
          this.disableFields(targetField)
        }
      })
    })
  }

  enableFields(container) {
    container.querySelectorAll('input, select, textarea').forEach((field) => (field.disabled = false))
  }

  disableFields(container) {
    container.querySelectorAll('input, select, textarea').forEach((field) => (field.disabled = true))
  }
}

2. 动态字段验证

javascript
class DynamicValidation {
  constructor(form) {
    this.form = form
    this.validationRules = new Map()
    this.errorMessages = new Map()
    this.setupValidation()
  }

  // 添加验证规则
  addValidationRule(fieldName, rule, message) {
    if (!this.validationRules.has(fieldName)) {
      this.validationRules.set(fieldName, [])
      this.errorMessages.set(fieldName, [])
    }
    this.validationRules.get(fieldName).push(rule)
    this.errorMessages.get(fieldName).push(message)
  }

  // 设置验证
  setupValidation() {
    this.form.addEventListener('submit', (e) => this.handleSubmit(e))

    // 实时验证
    this.form.querySelectorAll('input, select, textarea').forEach((field) => {
      field.addEventListener('input', () => this.validateField(field))
      field.addEventListener('blur', () => this.validateField(field))
    })
  }

  // 验证单个字段
  validateField(field) {
    const fieldName = field.name
    const rules = this.validationRules.get(fieldName)
    const messages = this.errorMessages.get(fieldName)

    if (!rules) return true

    // 清除之前的错误
    this.clearFieldError(field)

    // 检查每个规则
    for (let i = 0; i < rules.length; i++) {
      const isValid = rules[i](field.value)
      if (!isValid) {
        this.showFieldError(field, messages[i])
        return false
      }
    }

    return true
  }

  // 处理表单提交
  handleSubmit(event) {
    event.preventDefault()
    let isValid = true

    // 验证所有字段
    this.form.querySelectorAll('input, select, textarea').forEach((field) => {
      if (!this.validateField(field)) {
        isValid = false
      }
    })

    if (isValid) {
      // 表单验证通过,可以提交
      console.log('表单验证通过')
    }
  }

  // 显示字段错误
  showFieldError(field, message) {
    field.classList.add('invalid')

    // 创建或更新错误消息元素
    let errorElement = field.nextElementSibling
    if (!errorElement || !errorElement.classList.contains('error-message')) {
      errorElement = document.createElement('div')
      errorElement.classList.add('error-message')
      field.parentNode.insertBefore(errorElement, field.nextSibling)
    }
    errorElement.textContent = message
  }

  // 清除字段错误
  clearFieldError(field) {
    field.classList.remove('invalid')
    const errorElement = field.nextElementSibling
    if (errorElement && errorElement.classList.contains('error-message')) {
      errorElement.remove()
    }
  }
}

// 使用示例
const form = document.querySelector('#registration-form')
const validation = new DynamicValidation(form)

// 添加验证规则
validation.addValidationRule('username', (value) => value.length >= 3, '用户名至少需要3个字符')

validation.addValidationRule('email', (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value), '请输入有效的邮箱地址')

validation.addValidationRule('password', (value) => /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/.test(value), '密码至少8个字符,包含字母和数字')

// 添加CSS样式
const style = document.createElement('style')
style.textContent = `
  .invalid {
    border-color: #ff4444;
  }

  .error-message {
    color: #ff4444;
    font-size: 0.8em;
    margin-top: 4px;
  }
`
document.head.appendChild(style)

这个动态字段验证类提供了以下功能:

  1. 灵活的验证规则添加机制
  2. 实时验证和提交验证
  3. 清晰的错误消息显示
  4. 支持多个验证规则
  5. 自动样式处理

通过这种方式,我们可以根据需要动态添加和管理表单验证规则,同时保持代码的可维护性和可扩展性。