ux-form-design

Form patterns for data collection, validation, and user feedback. This skill covers accessible form design with custom elements.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "ux-form-design" with this command: npx skills add matthewharwood/fantasy-phonics/matthewharwood-fantasy-phonics-ux-form-design

UX Form Design Skill

Form patterns for data collection, validation, and user feedback. This skill covers accessible form design with custom elements.

Form-Associated Custom Elements

Basic Setup

Important: Store element references during construction - NEVER use querySelector.

class CustomInput extends HTMLElement { static formAssociated = true;

// Direct element references - created in constructor #input; #label; #hint; #error;

constructor() { super(); this.internals = this.attachInternals(); this.attachShadow({ mode: 'open' });

// Build DOM and store direct references
this.#label = document.createElement('label');
this.#label.setAttribute('part', 'label');

this.#input = document.createElement('input');
this.#input.setAttribute('part', 'input');

this.#hint = document.createElement('span');
this.#hint.className = 'hint';
this.#hint.setAttribute('part', 'hint');

this.#error = document.createElement('span');
this.#error.className = 'error';
this.#error.setAttribute('role', 'alert');
this.#error.setAttribute('part', 'error');

// Assemble shadow DOM
const field = document.createElement('div');
field.className = 'field';
field.appendChild(this.#label);
field.appendChild(this.#input);
field.appendChild(this.#hint);
field.appendChild(this.#error);

this.shadowRoot.appendChild(field);

}

connectedCallback() { this.addEventListener('input', this); this.addEventListener('blur', this); }

disconnectedCallback() { this.removeEventListener('input', this); this.removeEventListener('blur', this); }

// Required: Set form value set value(val) { this.#input.value = val; this.internals.setFormValue(val); }

get value() { return this.#input.value; }

// Form lifecycle formResetCallback() { this.value = ''; }

formDisabledCallback(disabled) { this.toggleAttribute('disabled', disabled); this.#input.disabled = disabled; } }

Validation

validate() { const value = this.#input.value.trim(); // Direct reference

if (!value && this.hasAttribute('required')) { this.internals.setValidity( { valueMissing: true }, 'This field is required', this.#input // Direct reference ); this.setAttribute('aria-invalid', 'true'); return false; }

// Clear validation this.internals.setValidity({}); this.removeAttribute('aria-invalid'); return true; }

Input Field Structure

Anatomy

<div class="field"> <label class="label" for="input-id">Field Label</label> <input class="input" id="input-id" aria-describedby="hint-id error-id"> <span class="hint" id="hint-id">Optional hint text</span> <span class="error" id="error-id" role="alert"></span> </div>

CSS

.field { display: flex; flex-direction: column; gap: var(--space-2xs); }

.label { font-family: var(--font-display); font-size: var(--step--1); font-weight: 600; color: var(--theme-on-surface); }

.input { padding: var(--space-s); border: 1px solid var(--theme-outline); border-radius: var(--space-2xs); background: var(--theme-surface-variant); color: var(--theme-on-surface); font-size: var(--step-0); font-family: var(--font-sans); }

.input:focus { outline: none; border-color: var(--theme-primary); box-shadow: 0 0 0 3px var(--color-active-overlay); }

.hint { font-size: var(--step--2); color: var(--theme-on-surface-variant); }

.error { font-size: var(--step--2); color: var(--color-error); }

Textarea (Auto-Resize)

Modern Approach (field-sizing)

.textarea { field-sizing: content; min-height: 3lh; max-height: 12lh; overflow-y: auto; }

Fallback for Older Browsers

Use direct element references (created in constructor):

class AutoResizeTextarea extends HTMLElement { #textarea; // Direct reference - NO querySelector #maxHeight = 300;

constructor() { super(); this.attachShadow({ mode: 'open' });

this.#textarea = document.createElement('textarea');
this.#textarea.setAttribute('part', 'textarea');
this.shadowRoot.appendChild(this.#textarea);

}

connectedCallback() { if (!CSS.supports('field-sizing', 'content')) { this.addEventListener('input', this); } }

disconnectedCallback() { this.removeEventListener('input', this); }

handleEvent(e) { if (e.type === 'input') { this.#autoResize(); } }

#autoResize() { this.#textarea.style.height = 'auto'; this.#textarea.style.height = ${Math.min(this.#textarea.scrollHeight, this.#maxHeight)}px; } }

Form Layout

Vertical Stack

.form { display: flex; flex-direction: column; gap: var(--space-m); }

Inline Fields

.form-row { display: flex; gap: var(--space-s); flex-wrap: wrap; }

.form-row > * { flex: 1; min-width: 150px; }

Form Actions

.form-actions { display: flex; justify-content: flex-end; gap: var(--space-s); margin-block-start: var(--space-m); }

Validation Patterns

Real-Time Validation

handleEvent(e) { if (e.type === 'input') { // Validate on input after first blur if (this.#touched) { this.validate(); } } if (e.type === 'blur') { this.#touched = true; this.validate(); } }

Submit Validation

Use direct element references (stored during construction):

// Assumes #input, #container, #error are private fields submit() { const value = this.#input.value.trim(); // Direct reference

if (!value) { this.#input.focus(); // Direct reference this.internals.setValidity( { valueMissing: true }, 'Please enter a value', this.#input // Direct reference ); // Visual shake feedback using Anime.js import { shake } from '../../utils/animations.js'; shake(this.#container); // Direct reference return; }

// Clear and submit this.internals.setValidity({}); this.dispatchEvent(new CustomEvent('form-submit', { bubbles: true, composed: true, detail: { value } })); }

Error Display

// Assumes #error and #input are private fields showError(message) { this.#error.textContent = message; // Direct reference this.setAttribute('aria-invalid', 'true'); }

clearError() { this.#error.textContent = ''; // Direct reference this.removeAttribute('aria-invalid'); }

Accessibility Requirements

Labels

Every input MUST have an associated label:

<!-- Explicit association --> <label for="name">Name</label> <input id="name">

<!-- Implicit association --> <label> Name <input> </label>

<!-- ARIA label for icon-only --> <input aria-label="Search">

Required Fields

<label> Email <span aria-hidden="true">*</span> <span class="sr-only">(required)</span> </label> <input required aria-required="true">

Error Association

<input aria-invalid="true" aria-describedby="email-error"> <span id="email-error" role="alert">Please enter a valid email</span>

Keyboard Submission

Support Ctrl/Cmd+Enter for textarea forms:

handleEvent(e) { if (e.type === 'keydown') { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.submit(); } } }

Input Types

Text Variations

<input type="text" inputmode="text"> <input type="email" inputmode="email"> <input type="tel" inputmode="tel"> <input type="url" inputmode="url"> <input type="number" inputmode="numeric">

Autocomplete

<input name="name" autocomplete="name"> <input name="email" autocomplete="email"> <input name="current-password" autocomplete="current-password">

Placeholder Best Practices

Do

/* Subtle placeholder */ .input::placeholder { color: var(--theme-on-surface-variant); opacity: 0.7; }

Don't

  • Never use placeholder as label replacement

  • Avoid long placeholder text

  • Don't include required format in placeholder alone

Correct Pattern

<label for="phone">Phone Number</label> <input id="phone" placeholder="555-555-5555" aria-describedby="phone-format"> <span id="phone-format" class="hint">Format: XXX-XXX-XXXX</span>

Touch Targets

Ensure inputs meet minimum touch target size:

.input { min-height: var(--min-touch-target); padding: var(--space-s); }

.checkbox-wrapper { min-width: var(--min-touch-target); min-height: var(--min-touch-target); display: flex; align-items: center; justify-content: center; }

Disabled vs Read-Only

/* Disabled: Cannot interact */ .input:disabled { opacity: 0.6; cursor: not-allowed; background: var(--theme-surface); }

/* Read-only: Can select/copy but not edit */ .input:read-only { background: var(--theme-surface); border-style: dashed; }

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

ux-spacing-layout

No summary provided by upstream source.

Repository SourceNeeds Review
General

animejs-v4

No summary provided by upstream source.

Repository SourceNeeds Review
General

audio-design

No summary provided by upstream source.

Repository SourceNeeds Review
General

web-components-architecture

No summary provided by upstream source.

Repository SourceNeeds Review