ZDDC/zddc/internal/handler/form.html
ZDDC 633411770c
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 4s
chore(embedded): cut v0.0.16-beta
Bake the standardized headers + archive bugfix + browse refactor
into the dev binary. Triggers notify-chart-dev → bumps tnd-zddc-chart
develop with appVersion=0.0.16-beta-<sha>.
2026-05-03 22:22:13 -05:00

1726 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Form</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
<style>
/* ==========================================================================
ZDDC Shared Base — single source of truth for tokens and primitives
Included first by every tool's build.sh via ../shared/base.css
========================================================================== */
/* ── CSS custom properties ────────────────────────────────────────────────── */
:root {
/* Brand / accent (matches zddc.varasys.io website --accent) */
--primary: #2a5a8a;
--primary-hover: #1d4060;
--primary-active: #163352;
--primary-light: #e8f0f7;
/* Semantic colours */
--success: #28a745;
--warning: #d97706;
--danger: #dc3545;
--info: #17a2b8;
/* Backgrounds */
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-hover: #f0f4f8;
--bg-selected: var(--primary-light);
/* Text */
--text: #212529;
--text-muted: #6c757d;
--text-light: #ffffff;
/* Borders */
--border: #dee2e6;
--border-dark: #adb5bd;
/* Shape */
--radius: 4px;
/* Typography */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
}
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
/* Applied via: OS preference (auto) or [data-theme="dark"] on <html> */
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
}
/* Manual dark override — wins over media query */
[data-theme="dark"] {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
/* ── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base document ────────────────────────────────────────────────────────── */
html, body {
height: 100%;
font-family: var(--font);
font-size: 16px;
line-height: 1.5;
color: var(--text);
background-color: var(--bg-secondary);
}
/* ── Typography ───────────────────────────────────────────────────────────── */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.2;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ── Utility ──────────────────────────────────────────────────────────────── */
.hidden {
display: none !important;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Scrollbars (webkit) ──────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
/* ── Button primitive ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.85rem;
font-family: var(--font);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4;
text-align: center;
text-decoration: none;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: var(--radius);
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s;
background: var(--bg-secondary);
color: var(--text);
}
.btn:disabled,
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.btn:not(:disabled):hover {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.btn:not(:disabled):active {
box-shadow: none;
}
/* Variants */
.btn-primary {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.btn-primary:not(:disabled):hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--text-light);
}
.btn-primary:not(:disabled):active {
background: var(--primary-active);
border-color: var(--primary-active);
}
.btn-secondary {
background: var(--bg);
color: var(--text);
border-color: var(--border);
}
.btn-secondary:not(:disabled):hover {
background: var(--bg-secondary);
}
/* Subdued / de-emphasized variant.
Used on the "Add Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already
has the directory loaded from the server. */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:not(:disabled):hover {
color: var(--text);
background: var(--bg-secondary);
}
.btn-success {
background: var(--success);
color: var(--text-light);
border-color: var(--success);
}
.btn-danger {
background: var(--danger);
color: var(--text-light);
border-color: var(--danger);
}
/* Sizes */
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.btn-lg {
padding: 0.6rem 1.4rem;
font-size: 1rem;
}
.btn-link {
background: transparent;
border-color: transparent;
color: var(--primary);
padding-left: 0;
padding-right: 0;
}
.btn-link:not(:disabled):hover {
text-decoration: underline;
box-shadow: none;
}
/* ── App header chrome ────────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* Tool name inside the header */
.app-header__title {
font-size: 17px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.01em;
white-space: nowrap;
}
/* Brand logo — sits left of the title in every tool's app-header.
Self-contained: the SVG provides its own dark blue rounded background,
so no extra wrapper styling is needed. */
.app-header__logo {
width: 26px;
height: 26px;
flex-shrink: 0;
display: block;
}
/* ── Build timestamp ──────────────────────────────────────────────────────── */
.build-timestamp {
font-size: 0.55rem;
color: var(--text-muted);
opacity: 0.7;
font-weight: 300;
white-space: nowrap;
padding-top: 0.15rem;
}
/* Title + timestamp stacked vertically on the left side of the header */
.header-title-group {
display: flex;
flex-direction: column;
gap: 0;
line-height: 1;
}
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
/* Square, centered — overrides the asymmetric text-button padding/line-height */
#help-btn,
#theme-btn,
#refreshHeaderBtn {
width: 2rem;
height: 2rem;
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
#theme-btn,
#help-btn {
font-size: 1rem;
}
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
/* Used by all four tools. Toggle open/close via shared/help.js. */
.help-panel {
position: fixed;
top: 0;
right: 0;
width: min(420px, 85vw);
height: 100vh;
z-index: 1000;
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.help-panel:not([hidden]) {
transform: translateX(0);
}
.help-panel[hidden] {
display: flex;
transform: translateX(100%);
pointer-events: none;
}
.help-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg);
}
.help-panel__title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.help-panel__close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.35rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.help-panel__close:hover {
color: var(--text);
background: var(--bg-secondary);
}
.help-panel__body {
flex: 1;
overflow-y: auto;
padding: 1rem 1rem 2rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.help-panel__body h3 {
font-size: 0.95rem;
font-weight: 700;
margin: 1.25rem 0 0.35rem;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 0.15rem;
}
.help-panel__body h3:first-child {
margin-top: 0;
}
.help-panel__body h4 {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 1.25rem 0 0.3rem;
padding-left: 0.5rem;
border-left: 3px solid var(--border-dark);
color: var(--text-muted);
}
.help-panel__body p {
margin: 0 0 0.5rem;
}
.help-panel__body ol,
.help-panel__body ul {
padding-left: 1.5rem;
margin: 0.3rem 0 0.5rem;
}
.help-panel__body li {
margin-bottom: 0.3rem;
}
.help-panel__body dl {
margin: 0.3rem 0;
}
.help-panel__body dt {
font-weight: 600;
color: var(--text);
}
.help-panel__body dd {
margin: 0 0 0.5rem 1rem;
color: var(--text-muted);
}
.help-panel__body code {
font-family: var(--font-mono);
font-size: 0.8em;
background: var(--bg-secondary);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.help-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: var(--radius);
vertical-align: middle;
letter-spacing: 0.02em;
}
.help-badge--draft {
color: #2563eb;
background: #eff6ff;
}
.help-badge--published {
color: #7c3aed;
background: #f5f3ff;
}
/* Shrink main content when help panel is open */
body.help-open .app-header {
margin-right: min(420px, 85vw);
}
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
.column-filter {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.25rem;
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
font-family: var(--font);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
transition: border-color 0.15s;
}
.column-filter:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
}
.column-filter::placeholder {
color: var(--text-muted);
}
/* form/ — ZDDC generic form renderer.
Pulls theme tokens from shared/base.css; only adds form-specific layout. */
.form-main {
max-width: 800px;
margin: 1.5rem auto;
padding: 0 1rem 4rem;
}
.form-status {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 4px;
border: 1px solid var(--color-border);
}
.form-status.is-error {
background: var(--color-bg-alt);
border-color: #c43;
color: #c43;
}
.form-status.is-success {
background: var(--color-bg-alt);
border-color: #283;
color: #283;
}
.form-root {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__label {
font-weight: 600;
font-size: 0.95rem;
}
.form-field__label .required-mark {
color: #c43;
margin-left: 0.15rem;
}
.form-field__description {
font-size: 0.85rem;
color: var(--color-text-muted, #666);
}
.form-field__error {
font-size: 0.85rem;
color: #c43;
margin-top: 0.15rem;
}
.form-field__help {
font-size: 0.8rem;
color: var(--color-text-muted, #666);
font-style: italic;
}
.form-field__input,
.form-field__textarea,
.form-field__select {
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
font: inherit;
width: 100%;
box-sizing: border-box;
}
.form-field__textarea {
min-height: 5em;
resize: vertical;
}
.form-field__input:focus,
.form-field__textarea:focus,
.form-field__select:focus {
outline: 2px solid var(--color-primary, #1e3a5f);
outline-offset: -1px;
}
.form-field--invalid .form-field__input,
.form-field--invalid .form-field__textarea,
.form-field--invalid .form-field__select {
border-color: #c43;
}
.form-field__radio-group,
.form-field__checkbox-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__radio-group label,
.form-field__checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 400;
cursor: pointer;
}
.form-fieldset {
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-fieldset__legend {
font-weight: 600;
padding: 0 0.4rem;
}
.form-array {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-array__row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.5rem;
background: var(--color-bg-alt, #f6f6f8);
}
.form-array__row-body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-array__row-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-array__add {
align-self: flex-start;
}
.form-actions {
margin-top: 1.5rem;
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-bg, #fff);
color: var(--color-text, #111);
cursor: pointer;
font: inherit;
}
.btn:hover {
background: var(--color-bg-alt, #f6f6f8);
}
.btn-primary {
background: var(--color-primary, #1e3a5f);
color: #fff;
border-color: var(--color-primary, #1e3a5f);
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-small {
padding: 0.2rem 0.5rem;
font-size: 0.85rem;
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body>
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title" id="form-title">ZDDC Form</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · e67c1b2</span></span>
</div>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>
</header>
<main class="form-main">
<div id="form-status" class="form-status" hidden></div>
<form id="form-root" class="form-root" novalidate></form>
<div class="form-actions">
<button type="button" id="submit-btn" class="btn btn-primary">Submit</button>
</div>
</main>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Form</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is this form?</h3>
<p>This is a schema-driven form rendered by zddc-server. Every
<code>&lt;name&gt;.form.yaml</code> file in the archive becomes an
editable form at <code>&lt;path&gt;/&lt;name&gt;.form.html</code>.
Submissions are saved as <code>&lt;name&gt;/&lt;id&gt;.yaml</code>
files alongside the schema, and re-render with their data filled in
when revisited.</p>
<h3>Filling in the form</h3>
<dl>
<dt>Required fields</dt>
<dd>Marked with an asterisk in their label. Submitting with a
required field empty re-renders the form with an inline error.</dd>
<dt>Validation</dt>
<dd>Server-side via JSON Schema 2020-12 (subset). Client-side
hints (<code>required</code>, <code>min</code>, <code>max</code>,
<code>pattern</code>) are added where the schema specifies them.</dd>
<dt>Submit</dt>
<dd>POSTs to the same URL the form was loaded from. On success the
browser navigates to the saved submission's URL. On failure the
form re-renders with errors inline at each invalid field.</dd>
</dl>
<h3>Editing existing submissions</h3>
<p>Open the saved submission's URL — the form re-renders with its
current data and any errors. Submitting overwrites the same file.
History is in git via your normal commit cycle.</p>
<h3>Header buttons</h3>
<dl>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
<dt>? Help</dt>
<dd>This panel. Press <kbd>Esc</kbd> to close.</dd>
</dl>
</div>
</aside>
<!--
Server injects the form context here on render. Shape:
{
"title": "Optional page title override",
"schema": { JSON Schema 2020-12 subset },
"ui": { RJSF-style ui:* hints, recursively keyed },
"data": { existing submission data, or null for empty form },
"submitUrl": "/path/to/submit",
"errors": [{path, message}] // only populated on POST→422 re-render
}
-->
<script id="form-context" type="application/json">{}</script>
<script>
/**
* ZDDC shared theme toggle — light / dark / auto.
* Persists choice to localStorage under 'zddc-theme'.
* Works with all four tools regardless of their module pattern.
* Expects: #theme-btn in the DOM (optional — skips gracefully if absent).
*
* Theme cycle: auto → light → dark → auto …
* 'auto' honours the OS prefers-color-scheme media query (CSS handles it).
* 'light' sets data-theme="light" on <html> (overrides dark media query).
* 'dark' sets data-theme="dark" on <html>.
*/
(function () {
'use strict';
var STORAGE_KEY = 'zddc-theme';
var THEMES = ['auto', 'light', 'dark'];
var LABELS = {
auto: '◐',
light: '☀',
dark: '☾'
};
var TITLES = {
auto: 'Theme: auto (follows OS)',
light: 'Theme: light',
dark: 'Theme: dark'
};
function load() {
var stored = localStorage.getItem(STORAGE_KEY);
return THEMES.indexOf(stored) !== -1 ? stored : 'auto';
}
function apply(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function save(theme) {
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
}
function updateButton(btn, theme) {
btn.textContent = LABELS[theme];
btn.title = TITLES[theme];
btn.setAttribute('aria-label', TITLES[theme]);
}
function next(theme) {
return THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
}
function init() {
var current = load();
apply(current);
var btn = document.getElementById('theme-btn');
if (!btn) { return; }
updateButton(btn, current);
btn.addEventListener('click', function () {
current = next(current);
apply(current);
save(current);
updateButton(btn, current);
});
}
/* Apply theme immediately (before DOM ready) to avoid flash */
apply(load());
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
/**
* ZDDC shared help panel — open/close logic.
* Works with all four tools regardless of their module pattern.
* Expects: #help-btn, #help-panel, #help-panel-close in the DOM.
*/
(function () {
'use strict';
function init() {
var helpBtn = document.getElementById('help-btn');
var panel = document.getElementById('help-panel');
var closeBtn = document.getElementById('help-panel-close');
if (!helpBtn || !panel) { return; }
function isOpen() { return !panel.hidden; }
function openPanel() {
panel.hidden = false;
document.body.classList.add('help-open');
}
function closePanel() {
panel.hidden = true;
document.body.classList.remove('help-open');
}
helpBtn.addEventListener('click', function () {
if (isOpen()) { closePanel(); } else { openPanel(); }
});
if (closeBtn) {
closeBtn.addEventListener('click', closePanel);
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) { closePanel(); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
(function (global) {
'use strict';
if (global.formApp) {
return;
}
global.formApp = {
context: null,
rootWidget: null,
modules: {}
};
})(window);
(function (app) {
'use strict';
function load() {
const el = document.getElementById('form-context');
if (!el) {
return {};
}
try {
return JSON.parse(el.textContent || '{}');
} catch (err) {
console.error('[form] failed to parse #form-context', err);
return {};
}
}
app.modules.context = { load };
})(window.formApp);
(function (app) {
'use strict';
const util = {};
util.h = function (tag, attrs) {
const el = document.createElement(tag);
if (attrs) {
for (const k of Object.keys(attrs)) {
const v = attrs[k];
if (v == null || v === false) {
continue;
}
if (k === 'className') {
el.className = v;
} else if (k.length > 2 && k.slice(0, 2) === 'on' && typeof v === 'function') {
el.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v === true) {
el.setAttribute(k, '');
} else {
el.setAttribute(k, v);
}
}
}
for (let i = 2; i < arguments.length; i++) {
const c = arguments[i];
if (c == null || c === false) {
continue;
}
if (typeof c === 'string' || typeof c === 'number') {
el.appendChild(document.createTextNode(String(c)));
} else {
el.appendChild(c);
}
}
return el;
};
// JSON Pointer (RFC 6901): encode one segment.
util.ptrEnc = function (s) {
return String(s).replace(/~/g, '~0').replace(/\//g, '~1');
};
util.ptrPush = function (path, segment) {
return path + '/' + util.ptrEnc(segment);
};
util.ptrParse = function (path) {
if (!path) {
return [];
}
return path.split('/').slice(1).map(function (s) {
return s.replace(/~1/g, '/').replace(/~0/g, '~');
});
};
let idCounter = 0;
util.uid = function (prefix) {
idCounter += 1;
return (prefix || 'f') + '-' + idCounter;
};
// Turn camelCase / snake_case into a Title Case string for default labels.
util.humanize = function (name) {
return String(name)
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/^./, function (c) { return c.toUpperCase(); });
};
app.modules.util = util;
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
// Build the standard label / description / input / help / error scaffold
// shared by all primitive widgets. Returns { wrap, errEl }.
function fieldContainer(opts) {
const wrap = u.h('div', { className: 'form-field' });
if (opts.label) {
const lbl = u.h('label', { className: 'form-field__label', for: opts.id });
lbl.appendChild(document.createTextNode(opts.label));
if (opts.required) {
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(lbl);
}
if (opts.description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, opts.description));
}
wrap.appendChild(opts.input);
if (opts.help) {
wrap.appendChild(u.h('div', { className: 'form-field__help' }, opts.help));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
return { wrap: wrap, errEl: errEl };
}
function coerceEnum(rawValue, options) {
for (let i = 0; i < options.length; i++) {
if (String(options[i]) === rawValue) {
return options[i];
}
}
return rawValue;
}
function makePrimitive(schema, ui, path, value, options) {
const id = u.uid('w');
const required = !!options.required;
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
const description = (ui && ui['ui:description']) || schema.description || '';
const help = (ui && ui['ui:help']) || '';
const placeholder = (ui && ui['ui:placeholder']) || '';
const widget = (ui && ui['ui:widget']) || '';
const readonly = !!(ui && ui['ui:readonly']);
const autofocus = !!(ui && ui['ui:autofocus']);
let input;
let read;
const t = schema.type;
if (t === 'boolean') {
// Render boolean as a single checkbox with an inline label, suppressing
// the standard label-above layout for cleaner UX.
const cb = u.h('input', { type: 'checkbox', id: id });
if (value === true) {
cb.checked = true;
}
if (readonly) {
cb.disabled = true;
}
const wrap = u.h('div', { className: 'form-field form-field--boolean' });
const inlineLabel = u.h('label', { for: id, className: 'form-field__checkbox-inline' });
inlineLabel.appendChild(cb);
inlineLabel.appendChild(document.createTextNode(' '));
inlineLabel.appendChild(document.createTextNode(label || ''));
if (required) {
inlineLabel.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(inlineLabel);
if (description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, description));
}
if (help) {
wrap.appendChild(u.h('div', { className: 'form-field__help' }, help));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
return widgetObject(wrap, errEl, path, function () {
return cb.checked;
});
}
if (Array.isArray(schema.enum)) {
const opts = schema.enum;
if (widget === 'radio') {
input = u.h('div', { className: 'form-field__radio-group' });
opts.forEach(function (opt, idx) {
const radioId = id + '-' + idx;
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) });
if (value === opt) {
radio.checked = true;
}
if (readonly) {
radio.disabled = true;
}
const lbl = u.h('label', { for: radioId });
lbl.appendChild(radio);
lbl.appendChild(document.createTextNode(' ' + String(opt)));
input.appendChild(lbl);
});
read = function () {
const checked = input.querySelector('input[type="radio"]:checked');
return checked ? coerceEnum(checked.value, opts) : undefined;
};
} else {
input = u.h('select', { id: id, className: 'form-field__select' });
if (!required) {
input.appendChild(u.h('option', { value: '' }, '— select —'));
}
opts.forEach(function (opt) {
const o = u.h('option', { value: String(opt) }, String(opt));
if (value === opt) {
o.selected = true;
}
input.appendChild(o);
});
if (readonly) {
input.disabled = true;
}
read = function () {
if (input.value === '') {
return undefined;
}
return coerceEnum(input.value, opts);
};
}
} else if (t === 'number' || t === 'integer') {
input = u.h('input', {
type: 'number',
id: id,
className: 'form-field__input',
step: t === 'integer' ? '1' : 'any'
});
if (placeholder) {
input.placeholder = placeholder;
}
if (value != null) {
input.value = String(value);
}
if (readonly) {
input.readOnly = true;
}
if (autofocus) {
input.autofocus = true;
}
read = function () {
const v = input.value.trim();
if (v === '') {
return undefined;
}
const n = Number(v);
// If the user typed something non-numeric, return the raw string and
// let server validation produce a friendly error.
return Number.isFinite(n) ? n : v;
};
} else {
// Default: string-shaped input.
const fmt = schema.format;
if (widget === 'textarea') {
input = u.h('textarea', { id: id, className: 'form-field__textarea' });
} else {
let inputType = 'text';
if (fmt === 'date') {
inputType = 'date';
} else if (fmt === 'email') {
inputType = 'email';
}
input = u.h('input', { type: inputType, id: id, className: 'form-field__input' });
}
if (placeholder) {
input.placeholder = placeholder;
}
if (value != null) {
input.value = String(value);
}
if (readonly) {
input.readOnly = true;
}
if (autofocus) {
input.autofocus = true;
}
read = function () {
return input.value === '' ? undefined : input.value;
};
}
const built = fieldContainer({
id: id,
label: label,
description: description,
help: help,
required: required,
input: input
});
return widgetObject(built.wrap, built.errEl, path, read);
}
// Common widget shape used by both primitive and the wrapper above.
function widgetObject(wrapEl, errEl, path, read) {
return {
el: wrapEl,
path: path,
type: 'primitive',
read: read,
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
wrapEl.classList.add('form-field--invalid');
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
wrapEl.classList.remove('form-field--invalid');
},
child: function () { return null; }
};
}
app.modules.widgets = { makePrimitive: makePrimitive };
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
function makeObject(schema, ui, path, value, options) {
const fs = u.h('fieldset', { className: 'form-fieldset' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName;
if (label) {
fs.appendChild(u.h('legend', { className: 'form-fieldset__legend' }, label));
}
if (schema.description) {
fs.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
fs.appendChild(errEl);
const props = schema.properties || {};
const requiredSet = {};
(schema.required || []).forEach(function (n) { requiredSet[n] = true; });
// Resolve render order: ui:order first (with '*' as "everything else"),
// then fall back to declaration order.
const declared = Object.keys(props);
const uiOrder = (ui && ui['ui:order']) || null;
const ordered = [];
const seen = {};
if (uiOrder && Array.isArray(uiOrder)) {
for (let i = 0; i < uiOrder.length; i++) {
const name = uiOrder[i];
if (name === '*') {
for (let j = 0; j < declared.length; j++) {
const dn = declared[j];
if (!seen[dn] && uiOrder.indexOf(dn) < 0) {
ordered.push(dn);
seen[dn] = true;
}
}
} else if (props[name] && !seen[name]) {
ordered.push(name);
seen[name] = true;
}
}
// Append anything declared but not mentioned in ui:order (and no '*' was used).
for (let j = 0; j < declared.length; j++) {
if (!seen[declared[j]]) {
ordered.push(declared[j]);
seen[declared[j]] = true;
}
}
} else {
for (let j = 0; j < declared.length; j++) {
ordered.push(declared[j]);
}
}
const children = {};
const dataObj = (value && typeof value === 'object' && !Array.isArray(value)) ? value : {};
for (let i = 0; i < ordered.length; i++) {
const name = ordered[i];
const childSchema = props[name];
const childUi = (ui && ui[name]) || {};
const childPath = u.ptrPush(path, name);
const childValue = dataObj[name];
const childWidget = app.modules.render.create(childSchema, childUi, childPath, childValue, {
fieldName: u.humanize(name),
required: !!requiredSet[name]
});
children[name] = childWidget;
fs.appendChild(childWidget.el);
}
return {
el: fs,
path: path,
type: 'object',
read: function () {
const out = {};
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const v = children[k].read();
if (v !== undefined) {
out[k] = v;
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
children[keys[i]].clearErrors();
}
},
child: function (name) {
return children[name] || null;
}
};
}
app.modules.object = { makeObject: makeObject };
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
function makeArray(schema, ui, path, value, options) {
const wrap = u.h('div', { className: 'form-field form-array' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
if (label) {
const lbl = u.h('label', { className: 'form-field__label' });
lbl.appendChild(document.createTextNode(label));
if (options.required) {
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(lbl);
}
if (schema.description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
const rowsEl = u.h('div', { className: 'form-array__rows' });
wrap.appendChild(rowsEl);
const itemSchema = schema.items || { type: 'string' };
const itemUi = (ui && ui.items) || {};
const uiOpts = (ui && ui['ui:options']) || {};
const addable = uiOpts.addable !== false;
const removable = uiOpts.removable !== false;
const rows = [];
function repath() {
for (let i = 0; i < rows.length; i++) {
rows[i].widget.path = u.ptrPush(path, String(i));
}
}
function addRow(rowValue) {
const idx = rows.length;
const rowPath = u.ptrPush(path, String(idx));
const childWidget = app.modules.render.create(itemSchema, itemUi, rowPath, rowValue, {
fieldName: '',
required: false
});
const rowEl = u.h('div', { className: 'form-array__row' });
const body = u.h('div', { className: 'form-array__row-body' });
body.appendChild(childWidget.el);
rowEl.appendChild(body);
if (removable) {
const actions = u.h('div', { className: 'form-array__row-actions' });
const removeBtn = u.h('button', {
type: 'button',
className: 'btn btn-small',
title: 'Remove this row',
onClick: function () { removeRow(rowEl); }
}, '×');
actions.appendChild(removeBtn);
rowEl.appendChild(actions);
}
rows.push({ widget: childWidget, rowEl: rowEl });
rowsEl.appendChild(rowEl);
}
function removeRow(targetEl) {
for (let i = 0; i < rows.length; i++) {
if (rows[i].rowEl === targetEl) {
rows.splice(i, 1);
targetEl.remove();
repath();
return;
}
}
}
const initial = Array.isArray(value) ? value : [];
for (let i = 0; i < initial.length; i++) {
addRow(initial[i]);
}
if (addable) {
const addBtn = u.h('button', {
type: 'button',
className: 'btn btn-small form-array__add',
onClick: function () { addRow(undefined); }
}, '+ Add');
wrap.appendChild(addBtn);
}
return {
el: wrap,
path: path,
type: 'array',
read: function () {
const out = [];
for (let i = 0; i < rows.length; i++) {
const v = rows[i].widget.read();
if (v !== undefined) {
out.push(v);
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
for (let i = 0; i < rows.length; i++) {
rows[i].widget.clearErrors();
}
},
child: function (idxStr) {
const i = parseInt(idxStr, 10);
return (rows[i] && rows[i].widget) || null;
}
};
}
app.modules.array = { makeArray: makeArray };
})(window.formApp);
(function (app) {
'use strict';
function create(schema, ui, path, value, options) {
options = options || {};
if (!schema) {
return app.modules.widgets.makePrimitive({ type: 'string' }, ui, path, value, options);
}
const t = schema.type;
if (t === 'object') {
return app.modules.object.makeObject(schema, ui, path, value, options);
}
if (t === 'array') {
return app.modules.array.makeArray(schema, ui, path, value, options);
}
// Anything else (string, number, integer, boolean, enum) falls through
// to the primitive widget which dispatches on schema.type / schema.enum.
return app.modules.widgets.makePrimitive(schema, ui, path, value, options);
}
function mount(rootEl, schema, ui, data) {
const widget = create(schema, ui, '', data, { fieldName: '', required: false });
rootEl.appendChild(widget.el);
return widget;
}
app.modules.render = { create: create, mount: mount };
})(window.formApp);
(function (app) {
'use strict';
function read() {
if (!app.rootWidget) {
return null;
}
return app.rootWidget.read();
}
app.modules.serialize = { read: read };
})(window.formApp);
(function (app) {
'use strict';
const u = app.modules.util;
function findByPath(root, path) {
if (!path || path === '') {
return root;
}
const segs = u.ptrParse(path);
let cur = root;
for (let i = 0; i < segs.length; i++) {
if (!cur || typeof cur.child !== 'function') {
return null;
}
cur = cur.child(segs[i]);
}
return cur || null;
}
function apply(errors) {
if (!errors || !errors.length || !app.rootWidget) {
return;
}
for (let i = 0; i < errors.length; i++) {
const err = errors[i];
const widget = findByPath(app.rootWidget, err.path || '');
if (widget && typeof widget.setError === 'function') {
widget.setError(err.message || 'Invalid value');
}
}
}
function clear() {
if (app.rootWidget) {
app.rootWidget.clearErrors();
}
}
app.modules.errors = { apply: apply, clear: clear };
})(window.formApp);
(function (app) {
'use strict';
function showStatus(msg, kind) {
const el = document.getElementById('form-status');
if (!el) {
return;
}
el.textContent = msg || '';
el.hidden = !msg;
el.classList.remove('is-error', 'is-success');
if (kind === 'error') {
el.classList.add('is-error');
} else if (kind === 'success') {
el.classList.add('is-success');
}
}
async function submit() {
if (!app.context || !app.context.submitUrl) {
showStatus('No submit URL configured.', 'error');
return;
}
const data = app.modules.serialize.read();
app.modules.errors.clear();
showStatus('', '');
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.disabled = true;
}
try {
const res = await fetch(app.context.submitUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.status === 200) {
showStatus('Saved.', 'success');
} else if (res.status === 201) {
const loc = res.headers.get('Location');
showStatus('Submitted.', 'success');
if (loc) {
// Capability URL for the new submission. Append .html to land
// on the form-rendered view of the just-saved data.
setTimeout(function () {
window.location.href = loc + '.html';
}, 400);
}
} else if (res.status === 422) {
let body = {};
try { body = await res.json(); } catch (e) { /* ignore */ }
app.modules.errors.apply(body.errors || []);
showStatus('Please correct the errors below.', 'error');
} else if (res.status === 403) {
showStatus('You are not allowed to submit here.', 'error');
} else if (res.status === 409) {
showStatus('A submission with this filename already exists.', 'error');
} else {
let detail = '';
try { detail = await res.text(); } catch (e) { /* ignore */ }
showStatus('Submission failed (' + res.status + ')' + (detail ? ': ' + detail : ''), 'error');
}
} catch (err) {
showStatus('Network error: ' + (err && err.message ? err.message : err), 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
}
}
}
app.modules.post = { submit: submit, showStatus: showStatus };
})(window.formApp);
(function (app) {
'use strict';
function boot() {
app.context = app.modules.context.load();
if (app.context.title) {
const t = document.getElementById('form-title');
if (t) {
t.textContent = app.context.title;
}
document.title = app.context.title + ' — ZDDC';
}
const root = document.getElementById('form-root');
if (root && app.context.schema) {
app.rootWidget = app.modules.render.mount(
root,
app.context.schema,
app.context.ui || {},
app.context.data
);
}
if (app.context.errors && app.context.errors.length) {
app.modules.errors.apply(app.context.errors);
app.modules.post.showStatus('Please correct the errors below.', 'error');
}
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.addEventListener('click', app.modules.post.submit);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})(window.formApp);
</script>
</body>
</html>