FormField
AppForm field wrapper: label + slot + hint + error. Exposes computed `id`, `aria-describedby` and `aria-invalid` via the `data-form-field` container — the static EJS counterpart of the NextJS react-hook-form binding.
We'll never share your email.
<%- include('modules/app/FormField', {
name: 'email',
label: 'Email',
hint: "We'll never share your email.",
children: ``
}) %>
Password must be at least 8 characters.
<%- include('modules/app/FormField', {
name: 'password',
label: 'Password',
required: true,
error: 'Password must be at least 8 characters.',
children: ``
}) %>
<%
// ─── FormField (EJS) ────────────────────────────────────────────────────────
//
// NextJS counterpart uses react-hook-form: it pulls `errors[name]` from the
// form context and exposes id/aria attributes via a render-prop child.
//
// EJS has no form context and no render-prop pattern, so:
// • Callers pass static `error` / `hint` props per render (no live validation).
// • The caller embeds the input markup directly inside `children`; this
// template wraps it in a slot whose root element carries the computed
// `id`, `aria-describedby`, and `aria-invalid` attributes via a
// `data-form-field` data-attribute pattern. Real inputs should mirror
// these on themselves (e.g. `id="<%= name %>"`) — the surrounding wrapper
// exposes them for tests / scripts that walk the DOM.
//
// <%- include('FormField', {
// name: 'email', label: 'Email', error: errors.email,
// children: ``
// }) %>
//
// The computed `_inputId` (== `name`) is mirrored onto the slot wrapper as
// `data-input-id` so consumers can locate the input without re-deriving it.
var _name = locals.name || locals.id || ('field-' + Math.random().toString(36).substr(2, 6));
var _label = locals.label || '';
var _hint = locals.hint || '';
var _error = locals.error || '';
var _required = !!locals.required;
var _className = locals.className || '';
var _inputId = _name;
var _hintId = _hint ? (_inputId + '-hint') : '';
var _errorId = _error ? (_inputId + '-error') : '';
var _describedBy = [_hintId, _errorId].filter(Boolean).join(' ');
%>
aria-describedby="<%= _describedBy %>"<% } %>
aria-invalid="<%= _error ? 'true' : 'false' %>"
>
<%- locals.children || '' %>
<% if (_hint && !_error) { %>
<%= _hint %>
<% } %>
<% if (_error) { %>
<%= _error %>
<% } %>