{"_id":"@archduck/gst-forms","_rev":"2-811375a5322892330297710eb7d614b5","name":"@archduck/gst-forms","dist-tags":{"latest":"0.1.1"},"versions":{"0.1.0":{"name":"@archduck/gst-forms","version":"0.1.0","keywords":["forms","plugin","configuration","validation","lifecycle"],"author":{"name":"Alan Aydelott"},"license":"MIT","_id":"@archduck/gst-forms@0.1.0","maintainers":[{"name":"archduck","email":"alan.aydelott@gmail.com"}],"homepage":"https://gitlab.com/Aydelott/gst-forms","bugs":{"url":"https://gitlab.com/Aydelott/gst-forms/-/issues"},"dist":{"shasum":"6f32812532996eae46c1a8594210550191ff882a","tarball":"https://registry.npmjs.org/@archduck/gst-forms/-/gst-forms-0.1.0.tgz","fileCount":8,"integrity":"sha512-pCsN4fdLy0qugpOgLUzcSvM82QHT+uZ9o/pf16a5rgsCGqUXGPuI5nmhZ+G9vu7mfFpSNpXU+NE2JVOBhv6rTA==","signatures":[{"sig":"MEUCIQC/v8CRu3o79L13FkV7LgilSVUL8Oq1gPE4HhN9BdLLEQIgDXiJoekPwx1mSCK74ej8sYvGztMK7Go+bybdlW7HLuo=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":351070},"main":"./dist/index.cjs","type":"module","module":"./dist/index.js","exports":{".":{"import":"./dist/index.js","require":"./dist/index.cjs"},"./style.css":"./dist/gst-forms.css"},"_npmUser":{"name":"archduck","email":"alan.aydelott@gmail.com"},"repository":{"url":"git+https://gitlab.com/Aydelott/gst-forms.git","type":"git"},"_npmVersion":"11.14.1","description":"Form plugin for gst-core: field definitions, actions, validation, and lifecycle hooks","directories":{},"_nodeVersion":"22.22.2","dependencies":{"@archduck/gst-core":"^0.1.0"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/gst-forms_0.1.0_1780100503853_0.28174957517350196","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@archduck/gst-forms","version":"0.1.1","type":"module","description":"Form plugin for gst-core: field definitions, actions, validation, and lifecycle hooks","keywords":["forms","plugin","configuration","validation","lifecycle"],"author":{"name":"Alan Aydelott"},"license":"MIT","repository":{"type":"git","url":"git+https://gitlab.com/Aydelott/gst-forms.git"},"homepage":"https://gitlab.com/Aydelott/gst-forms","bugs":{"url":"https://gitlab.com/Aydelott/gst-forms/-/issues"},"main":"./dist/index.cjs","module":"./dist/index.js","exports":{".":{"import":"./dist/index.js","require":"./dist/index.cjs"},"./style.css":"./dist/gst-forms.css"},"scripts":{"build":"vite build","dev":"vite build --watch","test":"vitest","test:run":"vitest run","test:coverage":"vitest run --coverage"},"dependencies":{"@archduck/gst-core":"^0.1.0"},"devDependencies":{"vite":"^6.0.0"},"gitHead":"2b215dbe4d2f769dbef09cb33731e761cb45175c","_id":"@archduck/gst-forms@0.1.1","_nodeVersion":"22.22.2","_npmVersion":"11.14.1","dist":{"integrity":"sha512-Y0WtuqSjHp+yhZnToAqgs/rFGYXzTjDCltexQC1DzmtMn698mz/b1LqjES3W0Z5XBwC4Y6bjxOVHhXwM8JYDdA==","shasum":"3780750f1d135dacb677095c2c36b47ec734d70e","tarball":"https://registry.npmjs.org/@archduck/gst-forms/-/gst-forms-0.1.1.tgz","fileCount":8,"unpackedSize":351319,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCt1Tkk+EmTkxrrdwLnU72iuLHACnhO8YWqm6tGJc4mXQIhANOrSNZ2rQRsDPQNVqbwsV+H3nFmOLZVScCOgYfywL+u"}]},"_npmUser":{"name":"archduck","email":"alan.aydelott@gmail.com"},"directories":{},"maintainers":[{"name":"archduck","email":"alan.aydelott@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/gst-forms_0.1.1_1780353840805_0.7984404373194007"},"_hasShrinkwrap":false}},"time":{"created":"2026-05-30T00:21:43.600Z","modified":"2026-06-01T22:44:01.085Z","0.1.0":"2026-05-30T00:21:44.015Z","0.1.1":"2026-06-01T22:44:00.972Z"},"bugs":{"url":"https://gitlab.com/Aydelott/gst-forms/-/issues"},"author":{"name":"Alan Aydelott"},"license":"MIT","homepage":"https://gitlab.com/Aydelott/gst-forms","keywords":["forms","plugin","configuration","validation","lifecycle"],"repository":{"type":"git","url":"git+https://gitlab.com/Aydelott/gst-forms.git"},"description":"Form plugin for gst-core: field definitions, actions, validation, and lifecycle hooks","maintainers":[{"name":"archduck","email":"alan.aydelott@gmail.com"}],"readme":"# gst-forms\n\nForm plugin for `gst-core`. Adds field definitions, buttons, actions, validation, lifecycle hooks, and dirty tracking on top of the generic rendering engine.\n\n**gst** stands for **Grand Schema Things** -- a play on \"grand scheme of things.\" Forms are defined as schemas (JSON configurations) rather than code, and this library handles the form-specific concerns that sit on top of that config-driven foundation.\n\n```\nnpm install @archduck/gst-forms\n```\n\n```js\nimport { mountForm, loadJsonConfig } from '@archduck/gst-forms'\nimport '@archduck/gst-forms/style.css' // structural layout (not theming)\n```\n\nThe stylesheet uses `em` units throughout, so the entire form scales proportionally when you set `font-size` on the form container.\n\n---\n\n## Why forms need their own layer\n\nForms look simple. A few inputs, a submit button, done. Then you need to know whether the user changed anything. Then you need to validate before saving. Then you need different behavior for creating a new record vs updating an existing one. Then you need confirmation dialogs when the user navigates away with unsaved changes. Then delete needs its own confirmation. Then you want hooks that fire before and after each action so you can log, audit, or transform data on the way through.\n\ngst-forms encodes that state machine as a plugin for gst-core. You declare fields, buttons, and hooks in config. The engine manages dirty tracking, validation, action lifecycles, confirmation dialogs, and state transitions. Your application code handles one thing: what happens when the user clicks save.\n\n```js\nmountForm(document.getElementById('app'), form, {\n  onAction: async ({ action, record }) => {\n    if (action === 'save') await api.save(record)\n  }\n})\n```\n\nEverything between the button click and that callback -- validation, before/after hooks, dirty state reset, error handling -- is the engine's job.\n\n---\n\n## Quick start\n\nInstall gst-forms (pulls in gst-core and gst-compose automatically):\n\n```bash\nnpm install @archduck/gst-forms\n```\n\nCreate a form from three JSON files and a few lines of JS:\n\n```\nmy-app/\n  public/\n    forms/\n      UserView/\n        fields.json\n        layout.json\n        buttons.json\n  src/\n    main.js\n  index.html\n```\n\n**fields.json** -- define your fields:\n\n```json\n{\n  \"username\": { \"label\": \"Username\", \"required\": true },\n  \"email\": { \"type\": \"email\", \"label\": \"Email\" }\n}\n```\n\n**layout.json** -- arrange fields into rows:\n\n```json\n[[\"username\"], [\"email\"]]\n```\n\n**buttons.json** -- pick from built-in buttons:\n\n```json\n[\"save\", \"cancel\"]\n```\n\n**main.js** -- load the config and mount:\n\n```js\nimport { mountForm, loadJsonConfig } from '@archduck/gst-forms'\n\nconst form = await loadJsonConfig('/forms/UserView')\n\nmountForm(document.getElementById('app'), form, {\n  onAction: async ({ action, record }) => {\n    if (action === 'save') {\n      await fetch('/api/users', { method: 'POST', body: JSON.stringify(record.data) })\n    }\n  }\n})\n```\n\nThat's it. The form renders with two-way data binding, dirty tracking, validation, and lifecycle hooks. No framework required.\n\nFields use standard HTML input types by default. Set `type` to `email`, `number`, `date`, `textarea`, `checkbox`, etc. For dropdowns, set `component` to `select` and provide `options`. For any other HTML element, use its tag name as the `component` value.\n\n---\n\n## Actions and lifecycle\n\n### Built-in actions\n\nFive actions handle common form operations:\n\n**save** -- Validates required fields, fires `beforeSave` -> `beforeCreate`/`beforeUpdate` -> your `onAction` callback -> `afterCreate`/`afterUpdate` -> `afterSave`.\n\n**cancel** -- Confirms if dirty (configurable via `meta.confirmCancel`), then resets to baseline.\n\n**delete** -- Confirms (configurable via `meta.confirmDelete`), marks as deleted. Save label changes to \"Undo Delete.\"\n\n**new** -- Confirms if dirty, clears to a blank record (or `meta.defaultRecord`).\n\n**duplicate** -- Confirms if dirty, clones current record with key cleared.\n\n### Action resolution\n\nWhen a button fires, the engine resolves the handler in priority order:\n\n1. **Default actions** (highest) -- built-in save, cancel, reset, delete, duplicate, new\n2. **Specific prop handlers** -- `onSave`, `onDelete`, etc. passed to `mountForm`\n3. **Generic `onAction` prop** -- single handler for all actions\n4. **Registry functions** (lowest) -- custom actions defined in the functions registry\n\n### Lifecycle hooks\n\nEvery action gets `before*` and `after*` hooks automatically. The hook name is derived from the action: `before${capitalize(actionName)}` / `after${capitalize(actionName)}`. Define a custom action named `publish` and `beforePublish`/`afterPublish` fire around it with no extra wiring.\n\nReturn `false` from any `before*` hook to cancel the action.\n\n```json\n{\n  \"actions\": {\n    \"hooks\": {\n      \"beforeSave\": \"{{validateForm}}\",\n      \"afterSave\": [\"{{logSave}}\", \"{{showToast}}\"],\n      \"onDirty\": \"{{enableAutoSave}}\"\n    }\n  }\n}\n```\n\nHooks for built-in actions:\n\n- `beforeSave` / `afterSave`, `beforeCreate` / `afterCreate`, `beforeUpdate` / `afterUpdate`\n- `beforeCancel` / `afterCancel`, `beforeDelete` / `afterDelete`\n- `beforeNew` / `afterNew`, `beforeDuplicate` / `afterDuplicate`\n\n### State-transition hooks\n\nThese fire on form state changes, not around actions:\n\n- `onChange` -- any field value changes\n- `onDirty` / `onClean` -- dirty state transitions\n- `onError` / `onRecover` -- validation error state transitions\n- `onLoad` / `onUnload` -- form mount/unmount\n- `beforeUnload` -- browser navigation when the form is dirty\n\nNote: hooks are NOT a registry. They don't use spread syntax. They're plain objects nested under `actions.hooks`.\n\n---\n\n## Form state\n\nThe form tracks five states derived from three flags (`hasKey`, `isDirty`, `isDeleted`):\n\n| State | hasKey | isDirty | isDeleted |\n|-------|--------|---------|-----------|\n| Loaded | yes | no | no |\n| Editing | yes | yes | no |\n| New | no | no | no |\n| Creating | no | yes | no |\n| Deleted | yes | no | yes |\n\nButton visibility and the save button's label adapt to the current state automatically.\n\n### Built-in buttons\n\n| Button | Shows when | Label |\n|--------|-----------|-------|\n| save | Always | \"Save\", \"Create\", or \"Undo Delete\" |\n| cancel | Dirty and not deleted | \"Cancel\" |\n| delete | Has key, not deleted | \"Delete\" |\n| new | Has key or dirty, not deleted | \"New\" |\n| duplicate | Has key, not deleted | \"Duplicate\" |\n\nOverride any button by defining it in your `buttons.json`. Reference defaults by name in a `buttonSet` array.\n\n---\n\n## Navigation guards\n\n`mountForm()` automatically registers a dirty-form guard and wires the browser's `beforeunload` event. When the form has unsaved changes:\n\n- Browser navigation triggers a native confirmation dialog\n- In-app navigation can be guarded by calling `controller.checkGuards()` before transitioning\n\n```js\nconst ctrl = mountForm(container, form, options)\n\nfunction navigate(url) {\n  const result = ctrl.checkGuards()\n  if (result !== true) {\n    if (!confirm(result)) return  // user chose to stay\n  }\n  // proceed with navigation\n}\n```\n\nDisable the built-in guard with `meta.preventNavigationWhenDirty: false`.\n\n---\n\n## Functions\n\nFunctions in `registries.functions` receive `(context, event)` with `this` bound to a stage object. Use regular functions (not arrow functions) to access `this`.\n\n### Context\n\n```js\n{\n  action,           // current action name\n  record,           // { key, data }\n  meta,             // form metadata\n  registries,       // all registries\n  form: {\n    isDirty, isSubmitting, isDeleted,\n    getValue(name), setValue(name, value),\n    setFieldError(name, msg), clearFieldError(name),\n    reset(), loadRecord(record),\n    isRequired(name), isReadonly(name)\n  }\n}\n```\n\n### Stage (this)\n\nFor field functions:\n- `this.value`, `this.initialValue`\n- `this.setValue(val)`, `this.clearValue()`\n- `this.setError(msg)`, `this.clearErrors()`\n- `this.focus()`, `this.blur()`\n- `this.def`, `this.path`\n\nFor button functions:\n- `this.isLoading`\n- `this.executeAction()`\n- `this.def`, `this.path`\n\n### The onChange timing trap\n\nInside an `onChange` handler, `context.form.getValue(fieldName)` may return the old value. The reactive store hasn't flushed yet when your handler runs.\n\nRead from `event.target.value` instead:\n\n```js\n// Wrong -- may return stale value\nexport function onRoleChange(context, event) {\n  const role = context.form.getValue('role')  // old value!\n  context.form.setValue('accessLevel', getMinLevel(role))\n}\n\n// Right -- read the new value from the event\nexport function onRoleChange(context, event) {\n  const role = event.target.value  // current value\n  context.form.setValue('accessLevel', getMinLevel(role))\n}\n```\n\nDynamic properties (`{{functionName}}`) are not affected -- they re-evaluate after the store updates, so they always see current values. The timing trap only applies to imperative onChange/onBlur handlers.\n\n---\n\n## Meta configuration\n\nControl form behavior through `meta.json`:\n\n```json\n{\n  \"confirmCancel\": true,\n  \"confirmDelete\": true,\n  \"confirmDuplicate\": true,\n  \"confirmNew\": true,\n  \"clearHiddenFields\": true,\n  \"preventNavigationWhenDirty\": true,\n  \"submitOnEnter\": true,\n  \"debug\": false,\n  \"recordKeyPath\": \"key\",\n  \"recordDataPath\": \"data\",\n  \"readonly\": [],\n  \"systemFields\": []\n}\n```\n\nAll values shown are defaults.\n\n---\n\n## Custom components\n\nRegister your own components with a `_adapter` object in the component registry:\n\n```js\nconst form = await loadJsonConfig('/forms/UserView')\n\nform.registries.components = {\n  ...form.registries.components,\n  DatePicker: {\n    _adapter: {\n      mount(container, { def, store }) {\n        const picker = document.createElement('my-date-picker')\n        picker.value = store.record.data[def.name]\n        picker.addEventListener('change', (e) => {\n          store.record.data[def.name] = e.target.value\n        })\n        container.appendChild(picker)\n        return { unmount() { container.innerHTML = '' } }\n      }\n    }\n  }\n}\n\nmountForm(document.getElementById('app'), form, { /* ... */ })\n```\n\nReference by name in field definitions:\n\n```json\n{\n  \"startDate\": { \"component\": \"DatePicker\", \"label\": \"Start Date\" }\n}\n```\n\n---\n\n## Config loading\n\n```js\nimport { loadJsonConfig, createLiveConfig } from '@archduck/gst-forms'\n\n// Load from files (form schema pre-applied)\nconst form = await loadJsonConfig('./UserView')\n\n// With variant overlays\nconst form = await loadJsonConfig('./UserView', { patches: ['uchealth', 'admin'] })\n\n// With translation map\nconst form = await loadJsonConfig('./UserView', { patches: ['uchealth'], map: 'es' })\n```\n\nFile naming: `property[-variant][~map].{json|js}`. Variants stack left to right. Maps are applied after all variants have merged.\n\n### Runtime patching\n\n```js\nconst live = createLiveConfig()\nawait live.load('./UserView')\nlive.patch('fields', { email: { required: true } })\nconst form = live.get()\n```\n\nPatches cascade through dependencies. If `workEmail` spreads from `email`, patching `email` re-resolves `workEmail` too.\n\n### Update without unmounting\n\n```js\nconst ctrl = mountForm(container, form, options)\n\n// Later, load a different record or a different config\nctrl.update(newRecord)\n```\n\nThis preserves DOM state, scroll position, and focus while swapping the underlying data.\n\n---\n\n## Debug API\n\nWhen mounted, the form exposes `window.gst`:\n\n```js\nwindow.gst.record          // current record\nwindow.gst.data            // shorthand for record.data\nwindow.gst.isDirty         // dirty state\nwindow.gst.errors          // field errors\n\nwindow.gst.getValue('email')\nwindow.gst.setValue('email', 'new@example.com')\nwindow.gst.executeAction('save')\n\nwindow.gst.getField('email')\nwindow.gst.listFields()\nwindow.gst.listButtons()\nwindow.gst.listFunctions()\n```\n\n---\n\n## API reference\n\n### Functions\n\n- **`mountForm(container, config, options)`** -- Mount a form. Returns controller with `update`, `executeAction`, `checkGuards`, `unmount`, `store`.\n- **`loadJsonConfig(path, options?)`** -- Load form config with form schema. Options include `patches` (variant array) and `map` for translations.\n- **`createLiveConfig(options?)`** -- Create LiveConfig with form schema.\n\n### Defaults\n\n- **`defaultActions`** -- Built-in action handlers (`save`, `cancel`, `delete`, `new`, `duplicate`).\n- **`defaultButtons`** -- Built-in button definitions with dynamic visibility.\n- **`coreButtonFunctions`** -- Functions used by default buttons (`showSave`, `saveLabel`, etc.).\n- **`META_DEFAULTS`** -- Default meta configuration values.\n\n### Schema\n\n- **`FORM_TLP_DECLARATIONS`** -- Raw TLP declarations for form properties.\n- **`formSchema`** -- Pre-built schema from declarations (`resolutionOrder`, `dependencies`, `registryMapping`, `validRegistries`, `getDefault`).\n\n### Utilities\n\n- **`getRecordData(record, meta)`** / **`setRecordData(record, meta, data)`** -- Access record data respecting `meta.recordDataPath`.\n- **`getRecordKey(record, meta)`** -- Access record key respecting `meta.recordKeyPath`.\n- **`executeHooks(hookName, context, data)`** -- Run hook functions.\n- **`validateConfig(config)`** -- Validate form config completeness.\n- **`sanitizeHTML(html)`** / **`escapeHTML(str)`** / **`sanitizeInput(value, type)`** -- Security utilities.\n","readmeFilename":"README.md"}