diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index cc8bff6c..7cdb2880 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -16,6 +16,8 @@ jobs:
run: npm install
- name: Test
run: npm test
+ - name: Type-check public types
+ run: npm run test:types
- name: Build library
run: npm run build:lib
- name: Test built package
diff --git a/CLAUDE.md b/CLAUDE.md
index 50c4130d..21aab240 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,6 +23,7 @@ npm run build:lib # Build library for distribution (both minified and
### Testing
```bash
npm test # Run unit tests with Node.js native test runner
+npm run test:types # Type-check src/types/formeo.d.ts against its fixtures
npm run test:watch # Run tests in watch mode
npm run test:updateSnapshots # Update test snapshots
npm run playwright:test # Run e2e tests
@@ -136,7 +137,7 @@ The `FormeoRenderer` class (`/src/lib/js/renderer/index.js`) converts formData i
- **Internationalization**: Uses `@draggable/i18n` package. Language files loaded from CDN by default. See `/src/lib/js/config.js` for i18n config.
- **Icons**: SVG sprite generated at build time from `/src/lib/icons/`. Icon references use the pattern `#formeo-sprite-{icon-name}`.
- **SessionStorage**: Editor can persist formData to sessionStorage. Controlled by `sessionStorage` option.
-- **TypeScript**: No TypeScript source files, but type definitions should be maintained for consumers.
+- **TypeScript**: No TypeScript sources. Public types are hand-written in src/types/formeo.d.ts and copied to dist/formeo.d.ts + dist/formeo.d.cts by tools/build-types.mjs after build:lib. Any change to the public API (options, events, before hooks, actions, DOM events, methods, formData shape) must update that file and src/types/*.test-d.ts, and npm run test:types must pass. Keep FormeoFormData in sync with tools/formdata-schema.mjs.
## Git Hooks
@@ -153,6 +154,7 @@ The build process produces:
- `dist/formeo.umd.js` - UMD bundle (unpkg default)
- `dist/formeo.min.{es.js,cjs,umd.js}` - Minified versions
- `dist/formeo.min.css` - Compiled styles
+- `dist/formeo.d.ts`, `dist/formeo.d.cts` - TypeScript definitions (ESM/UMD and CommonJS)
- `dist/formeo-sprite.svg` - Icon sprite
- `dist/formData_schema.json` - JSON Schema for form data validation
diff --git a/README.md b/README.md
index 1937c0dd..22c205ab 100644
--- a/README.md
+++ b/README.md
@@ -125,10 +125,10 @@ import 'formeo/dist/formeo.min.css'
const editor = new FormeoEditor({
editorContainer: '#formeo-editor',
events: {
- onSave: (formData) => {
+ onSave: ({ formData }) => {
console.log('Form saved:', formData)
// Render the form
- renderer.render(formData.formData)
+ renderer.render(formData)
}
}
})
@@ -141,20 +141,31 @@ const renderer = new FormeoRenderer({
### TypeScript
-Formeo includes TypeScript definitions. Import and use with full type support:
+Formeo ships its own type definitions (`dist/formeo.d.ts`, `dist/formeo.d.cts`); TypeScript picks them up when you
+import `formeo`. See [TypeScript](https://github.com/Draggable/formeo/blob/main/docs/typescript.md) for what is typed.
```typescript
import { FormeoEditor, FormeoRenderer } from 'formeo'
-import type { FormeoOptions, FormData } from 'formeo'
+import type { FormeoEditorOptions, FormeoFormData } from 'formeo'
-const options: FormeoOptions = {
- editorContainer: '#formeo-editor'
+const renderer = new FormeoRenderer({ renderContainer: '#formeo-renderer' })
+
+const options: FormeoEditorOptions = {
+ editorContainer: '#formeo-editor',
+ events: {
+ onSave: ({ formData }) => renderer.render(formData),
+ },
}
const editor = new FormeoEditor(options)
-const formData: FormData = editor.formData
+editor.whenReady().then(() => {
+ const formData: FormeoFormData = editor.formData
+ console.log(Object.keys(formData.fields))
+})
```
+`FormeoOptions` and `FormData` still work as aliases of `FormeoEditorOptions` and `FormeoFormData`.
+
## Framework Integration
Formeo can be integrated with popular frontend frameworks:
@@ -202,6 +213,7 @@ Comprehensive documentation is available in the [docs](https://github.com/Dragga
- **[Events](https://github.com/Draggable/formeo/blob/main/docs/options/events/README.md)** - Available events and callbacks
- **[Actions](https://github.com/Draggable/formeo/blob/main/docs/options/actions/README.md)** - Action handlers
- **[Editor API](https://github.com/Draggable/formeo/blob/main/docs/editor/README.md)** - Editor methods and properties
+- **[TypeScript](https://github.com/Draggable/formeo/blob/main/docs/typescript.md)** - Shipped type definitions and typed events
- **[Build Tools](https://github.com/Draggable/formeo/blob/main/docs/tools/README.md)** - Development and build utilities
## Development
diff --git a/docs/README.md b/docs/README.md
index 1df34e00..338330f9 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -120,4 +120,8 @@ Remove the rendered form from the page and stop its pagination (the page navigat
- [Component Events](editor/component-events.md) - Component lifecycle event system
- [Clear Method](editor/editor-clear-method.md) - Resetting the editor to initial state
+## [TypeScript](typescript.md)
+
+Type definitions ship with the package; this page lists what is typed and how to narrow the before-hook details.
+
## [Build Tools](tools/)
diff --git a/docs/editor/README.md b/docs/editor/README.md
index da2728bb..37a325b7 100644
--- a/docs/editor/README.md
+++ b/docs/editor/README.md
@@ -18,7 +18,7 @@ const editor = new FormeoEditor(options, formData)
| Argument | Type | Description |
| ---------- | ---------------- | -------------------------------------------------------------------------------------------- |
-| `options` | Object | Required. See [Options](../options/README.md). Pass `{}` for defaults. |
+| `options` | Object | Optional. See [Options](../options/README.md). |
| `formData` | Object \| String | Optional form definition (or its JSON string) to load. Takes priority over `options.formData`. |
Set `editorContainer` to an element or selector, otherwise the editor is built but not attached to the page.
diff --git a/docs/options/actions/README.md b/docs/options/actions/README.md
index d7ebe998..5801be0d 100644
--- a/docs/options/actions/README.md
+++ b/docs/options/actions/README.md
@@ -11,8 +11,8 @@
| `remove.conditions` | Function | Called before a condition is removed with its × button, and by "Clear All" in the conditions panel (`evt.isClearAll: true`); call `evt.removeAction()` to remove |
| `remove.component` | Function | Called when a row, column or field's remove (×) button is clicked; call `evt.removeAction()` to remove it. Default: removes at once. See [Confirm before deleting](#confirm-before-deleting) |
| `remove.page` | Function | Called before a page is removed from its tab; call `evt.removeAction()` to remove it. Default: removes an empty page, asks first for a page with content. See [Page Tabs](../../editor/pages.md#actions) |
-| `click.button` | Function | Called when clicking a form action button |
-| `save` | Function | Called when saving |
+| `click.btn` | Function | Called when the Save button is clicked, just before `save.form`; `evt` is `{ action, coords, message, button }` |
+| `save.form` | Function | Called with the formData when the form is saved |
With Actions you can modify or completely replace some editor functions. By default, adding an attribute opens a small dialog in the editor. Define `add.attr` to use your own UI or extra validation.
diff --git a/docs/options/events/README.md b/docs/options/events/README.md
index 41fc0288..6fcd25a0 100644
--- a/docs/options/events/README.md
+++ b/docs/options/events/README.md
@@ -54,8 +54,8 @@ const editor = new FormeoEditor({
| `onBeforeSave` | Function | Runs before the Save button saves; can cancel or hold it. See [Before hooks](#before-hooks) |
| `onSave` | Function | Fires when form is saved |
| `onRender` | Function | Fires when an element is rendered |
-| `onEditOpen` | Function | Fires when a row, column or field's edit panel opens. See [Edit panel events](#edit-panel-events) |
-| `onEditClose` | Function | Fires when a row, column or field's edit panel closes. See [Edit panel events](#edit-panel-events) |
+| `onEditOpen` | Function | Fires when a stage, row, column or field's edit panel opens. See [Edit panel events](#edit-panel-events) |
+| `onEditClose` | Function | Fires when a stage, row, column or field's edit panel closes. See [Edit panel events](#edit-panel-events) |
| `onPageChange` | Function | Fires when the active page tab switches (with the `pages` option). See [Page Tabs](../../editor/pages.md#events) |
| `confirmClearAll` | Function | Fires when form is cleared |
@@ -106,8 +106,8 @@ document.addEventListener('formeoUpdatedField', (event) => {
| `formeoBeforeSave` | Before the Save button saves; cancelable. See [Before hooks](#before-hooks) |
| `formeoCleared` | Form has been cleared |
| `formeoOnRender` | Component has been rendered |
-| `formeoEditOpened` | A row, column or field's edit panel opened |
-| `formeoEditClosed` | A row, column or field's edit panel closed |
+| `formeoEditOpened` | A stage, row, column or field's edit panel opened |
+| `formeoEditClosed` | A stage, row, column or field's edit panel closed |
| `formeoConditionUpdated` | Conditional logic has been updated |
Like the callbacks, `formeoAddedRow`, `formeoAddedColumn` and `formeoAddedField` fire for components added after load, not for components loaded from `formData`.
@@ -186,10 +186,12 @@ don't run them.
## Edit panel events
-`onEditOpen` and `onEditClose` fire when a row, column or field's edit panel opens or closes, with
-`detail: { component, componentType, componentId }`. The `formeoEditOpened` and `formeoEditClosed` DOM events carry
-the same `detail`; they're dispatched on the component's element and bubble to `document`. They only fire when the
-panel actually opens or closes, can't be cancelled, and don't fire when a component is removed with its panel open.
+`onEditOpen` and `onEditClose` fire when a stage, row, column or field's edit panel opens or closes, with
+`detail: { component, componentType, componentId }`; `componentType` is `'stage'`, `'row'`, `'column'` or `'field'`
+(with the `pages` option, a stage's panel is a page's settings). The `formeoEditOpened` and `formeoEditClosed` DOM
+events carry the same `detail`; they're dispatched on the component's element and bubble to `document`. They only
+fire when the panel actually opens or closes, can't be cancelled, and don't fire when a component is removed with
+its panel open.
formBuilder's `onOpenFieldEdit`/`onCloseFieldEdit` map to these, filtered to `componentType === 'field'`.
```javascript
diff --git a/docs/typescript.md b/docs/typescript.md
new file mode 100644
index 00000000..6b45fe55
--- /dev/null
+++ b/docs/typescript.md
@@ -0,0 +1,122 @@
+# TypeScript
+
+Formeo ships hand-written type definitions: `dist/formeo.d.ts` for the ES module and UMD builds, and
+`dist/formeo.d.cts` for the CommonJS build. `package.json` points `types` and every `exports` condition at them, so
+`import { FormeoEditor } from 'formeo'` is typed with `moduleResolution` `bundler` or `node16`/`nodenext` (from ES
+modules or CommonJS). There is nothing extra to install. Projects still on `moduleResolution: node` (`node10`) get the
+types through the top-level `types` field on TypeScript 6 or older; TypeScript 7 removed that mode, so switch to
+`bundler` or `nodenext` there.
+
+Requires TypeScript 4.7+ (the `exports` conditions need `moduleResolution: node16`/`nodenext`, which 4.7 added;
+`moduleResolution: bundler` needs 5.0+). Your `lib` needs `"dom"` — without it, most of this file's types don't
+resolve (`HTMLElement`, `CustomEvent`, and the rest), and with the common `skipLibCheck: true` those errors are
+silently skipped because they'd be reported inside a `.d.ts` file; set `skipLibCheck: false` at least once to check.
+
+## What is typed
+
+- `FormeoEditor` and `FormeoRenderer`: constructors, properties and methods, including `destroy()`, `editor.pages`
+ and the renderer's `page` and `pageCount`. See [Editor](editor/README.md) and [Renderer](renderer/renderer.md).
+- Every editor option ([Options](options/README.md)), including `config`, `controls`, `i18n`, `events` and `actions`.
+- Every renderer option, including `pagination` and `events`.
+- The form definition, `FormeoFormData`, with its conditions. It matches `dist/formData_schema.json`.
+- Formeo's DOM events on `document`, so `evt.detail` is typed in `document.addEventListener('formeoBeforeSave', …)`.
+
+## Before hooks narrow on `componentType`
+
+The `detail` of `onBeforeAdd`, `onBeforeRemove` and the other [before hooks](options/events/README.md#before-hooks)
+depends on what is being added or removed. Check `componentType` before reading keys only some of them have:
+
+```typescript
+import { FormeoEditor } from 'formeo'
+
+new FormeoEditor({
+ editorContainer: '#formeo-editor',
+ events: {
+ onBeforeAdd: ({ detail }) => {
+ if (detail.componentType === 'stage') {
+ return detail.index < 5 // at most five pages
+ }
+ return detail.controlId !== 'hidden-input'
+ },
+ onBeforeRemove: async ({ detail }) => {
+ if (detail.componentType === 'stage') {
+ return detail.isEmpty || window.confirm(`Remove "${detail.title}"?`)
+ }
+ return true
+ },
+ },
+})
+```
+
+A hook cancels by returning `false`, or a Promise that resolves to `false`. Returning anything else, or nothing,
+lets the change happen.
+
+## DOM events
+
+```typescript
+document.addEventListener('formeoBeforeSave', evt => {
+ if (Object.keys(evt.detail.formData.fields).length === 0) {
+ evt.preventDefault()
+ }
+})
+```
+
+## Control sets
+
+`ControlDefinition` also covers [control sets](controls/custom-controls.md#control-sets). A set has no `tag` of its
+own, and `onBeforeAdd` reports it as `componentType: 'controlSet'` with `data: { layout, row, fields }`:
+
+```typescript
+import type { ControlDefinition } from 'formeo'
+
+const addressSet: ControlDefinition = {
+ meta: { group: 'common', id: 'address-set', icon: 'rows' },
+ config: { label: 'Address' },
+ controlSet: {
+ layout: 'stacked',
+ fields: [
+ { control: 'text-input', attrs: { name: 'street' }, config: { label: 'Street' } },
+ { control: 'text-input', attrs: { name: 'city' }, config: { label: 'City' } },
+ ],
+ },
+}
+```
+
+## Older names
+
+`FormeoOptions` and `FormData` are deprecated aliases of `FormeoEditorOptions` and `FormeoFormData`. Importing
+`FormData` from `formeo` hides the browser's `FormData` in that file, so prefer `FormeoFormData`.
+
+## Script tags
+
+With the UMD build, `window.FormeoEditor` and `window.FormeoRenderer` are typed once the definitions are part of your
+program, for example with `/// ` in one file.
+
+## What isn't typed
+
+- Formeo's internals: the component stores behind `editor.Components`, `editor.events` and `editor.actions`. The
+ stages, rows, columns and fields that events and actions hand you are typed as `FormeoComponent`, which only lists
+ their public part (`id`, `name`, `data`, `dom`, `parent`, `children`, `get`, `set`, `remove`, `addChild`).
+- The `Control` base class isn't published (see
+ [Or subclass `Control`](controls/custom-controls.md#or-subclass-control)), so `ControlClass` only helps controls
+ built from a source checkout.
+
+## Replacing your own declarations
+
+If your project has its own `declare module 'formeo'` block, delete it. What happens if you don't depends on where
+it lives and on `skipLibCheck`:
+
+- In a **module file** (one with its own `import`/`export`) with the common `skipLibCheck: true`, it merges with the
+ shipped types silently — the shipped declaration wins, and any error shows up where your code reads a member only
+ your shim declared, not where the shim is. With `skipLibCheck: false` it's a hard clash (TS2300 duplicate
+ identifier) at both declarations.
+- In a **global file** (no `import`/`export`) as a bodyless shorthand — `declare module 'formeo'` with no `{ }` —
+ it replaces the shipped types with `any`, regardless of `skipLibCheck`.
+
+If you declared `Window.FormeoEditor` or `FormeoRenderer` yourself, delete it: it now conflicts with formeo's own
+declaration. Depending on where it lives and your `skipLibCheck` setting, the conflict shows up as TS2717, as errors
+elsewhere in your code, or by silently replacing formeo's type.
+
+Since formeo's public surface used to be untyped (`any`) everywhere, removing a shim like this — or just upgrading
+past a version that lacked these types — can surface type errors in code that compiled before, now that TypeScript
+is actually checking it.
diff --git a/lefthook.yml b/lefthook.yml
index 5a4ff943..a2975410 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -3,6 +3,8 @@ pre-push:
commands:
run-tests:
run: npm test
+ type-check:
+ run: npm run test:types
pre-commit:
parallel: true
diff --git a/package-lock.json b/package-lock.json
index e920b237..efb2a915 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,6 +33,7 @@
"sass-embedded": "^1.80.1",
"semantic-release": "^25.0.2",
"svg-sprite": "^2.0.4",
+ "typescript": "^7.0.2",
"vite": "^8.0.13",
"vite-plugin-banner": "^0.8.0",
"vite-plugin-compression": "^0.5.1",
@@ -3217,6 +3218,346 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
"node_modules/@xmldom/xmldom": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
@@ -12215,18 +12556,38 @@
}
},
"node_modules/typescript": {
- "version": "5.6.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
- "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
+ "tsc": "bin/tsc"
},
"engines": {
- "node": ">=14.17"
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/uglify-js": {
diff --git a/package.json b/package.json
index 36448674..6e13c1a3 100644
--- a/package.json
+++ b/package.json
@@ -5,11 +5,21 @@
"main": "dist/formeo.cjs",
"module": "dist/formeo.es.js",
"unpkg": "dist/formeo.umd.js",
+ "types": "./dist/formeo.d.ts",
"exports": {
".": {
- "import": "./dist/formeo.es.js",
- "require": "./dist/formeo.cjs",
- "default": "./dist/formeo.umd.js"
+ "import": {
+ "types": "./dist/formeo.d.ts",
+ "default": "./dist/formeo.es.js"
+ },
+ "require": {
+ "types": "./dist/formeo.d.cts",
+ "default": "./dist/formeo.cjs"
+ },
+ "default": {
+ "types": "./dist/formeo.d.ts",
+ "default": "./dist/formeo.umd.js"
+ }
},
"./dist/formeo.min.css": {
"import": "./dist/formeo.min.css",
@@ -88,6 +98,7 @@
"test:updateSnapshots": "node --loader=./tools/svg-loader.mjs --import=./tools/__mocks__/sprite-init.mjs --experimental-test-snapshots --test-update-snapshots --require ./tools/test-setup.cjs --test --no-warnings src/**/*.test.{js,mjs}",
"test:ci": "npm test --coverage",
"test:dist": "node --test --no-warnings tools/dist.test.mjs",
+ "test:types": "tsc -p src/types/tsconfig.json",
"start": "npm-run-all build:icons dev",
"semantic-release": "semantic-release --ci --debug",
"copy:lang": "node ./tools/copy-directory.mjs ./node_modules/formeo-i18n/dist/lang ./src/demo/assets/lang",
@@ -99,7 +110,8 @@
"prepush": "npm test",
"prepare": "lefthook install",
"postmerge": "lefthook install",
- "generate:jsonSchema": "node --experimental-strip-types --no-warnings ./tools/generate-json-schema.ts"
+ "generate:jsonSchema": "node --experimental-strip-types --no-warnings ./tools/generate-json-schema.ts",
+ "postbuild:lib": "node tools/build-types.mjs"
},
"devDependencies": {
"@biomejs/biome": "^2.3.3",
@@ -119,6 +131,7 @@
"sass-embedded": "^1.80.1",
"semantic-release": "^25.0.2",
"svg-sprite": "^2.0.4",
+ "typescript": "^7.0.2",
"vite": "^8.0.13",
"vite-plugin-banner": "^0.8.0",
"vite-plugin-compression": "^0.5.1",
diff --git a/src/lib/js/package-types.test.mjs b/src/lib/js/package-types.test.mjs
new file mode 100644
index 00000000..65bf3601
--- /dev/null
+++ b/src/lib/js/package-types.test.mjs
@@ -0,0 +1,38 @@
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { suite, test } from 'node:test'
+import { buildTypes, TYPES_SOURCE } from '../../../tools/build-types.mjs'
+
+const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'))
+
+suite('published type definitions', () => {
+ test('package.json points every entry point at a declaration file', t => {
+ t.assert.strictEqual(pkg.types, './dist/formeo.d.ts')
+ const root = pkg.exports['.']
+ t.assert.deepStrictEqual(root.import, { types: './dist/formeo.d.ts', default: './dist/formeo.es.js' })
+ t.assert.deepStrictEqual(root.require, { types: './dist/formeo.d.cts', default: './dist/formeo.cjs' })
+ t.assert.deepStrictEqual(root.default, { types: './dist/formeo.d.ts', default: './dist/formeo.umd.js' })
+ for (const condition of Object.values(root)) {
+ t.assert.strictEqual(Object.keys(condition)[0], 'types', 'types must be the first key TypeScript sees')
+ }
+ })
+
+ test('the published files include dist/', t => {
+ t.assert.ok(pkg.files.includes('dist/*'))
+ })
+
+ test('buildTypes writes identical ESM and CJS declaration files', t => {
+ const outDir = mkdtempSync(join(tmpdir(), 'formeo-types-'))
+ t.after(() => rmSync(outDir, { recursive: true, force: true }))
+ const source = readFileSync(TYPES_SOURCE, 'utf8')
+ const written = buildTypes(outDir)
+ t.assert.deepStrictEqual(
+ written.map(file => file.slice(outDir.length + 1)),
+ ['formeo.d.ts', 'formeo.d.cts']
+ )
+ for (const file of written) {
+ t.assert.strictEqual(readFileSync(file, 'utf8'), source)
+ }
+ })
+})
diff --git a/src/types/control-sets.test-d.ts b/src/types/control-sets.test-d.ts
new file mode 100644
index 00000000..6b19bb7e
--- /dev/null
+++ b/src/types/control-sets.test-d.ts
@@ -0,0 +1,62 @@
+// Compile-only checks for control sets (#227). Run with `npm run test:types`.
+import {
+ type BeforeAddControlSetDetail,
+ type BeforeAddDetail,
+ type BeforeAddFieldDetail,
+ type ControlDefinition,
+ FormeoEditor,
+} from 'formeo'
+
+const addressSet: ControlDefinition = {
+ id: 'address-set-control',
+ meta: { group: 'common', id: 'address-set', icon: 'rows' },
+ config: { label: 'Address' },
+ controlSet: {
+ layout: 'stacked',
+ row: { config: { fieldset: true, legend: 'Address' } },
+ fields: [
+ { control: 'text-input', attrs: { name: 'street' }, config: { label: 'Street' } },
+ {
+ control: 'select',
+ attrs: { name: 'country' },
+ config: { label: 'Country' },
+ options: [{ label: 'Canada', value: 'ca', selected: false }],
+ },
+ { tag: 'input', attrs: { type: 'text', name: 'zip' }, config: { label: 'Zip' }, meta: { id: 'zip' } },
+ ],
+ },
+}
+
+const fieldCount = (detail: BeforeAddDetail): number =>
+ detail.componentType === 'controlSet' ? detail.data.fields.length : 0
+
+const editor = new FormeoEditor({
+ controls: { elements: [addressSet] },
+ events: {
+ onBeforeAdd: ({ detail }) => detail.componentType !== 'controlSet' || detail.data.layout === 'columns',
+ },
+})
+editor.controls?.addElement('address-set-control')
+
+// --- negative cases ---------------------------------------------------------------
+
+const badLayout: ControlDefinition = {
+ meta: { group: 'common', id: 'grid-set' },
+ config: { label: 'Grid' },
+ // @ts-expect-error a control set's layout is 'stacked' or 'columns'
+ controlSet: { layout: 'grid', fields: [] },
+}
+
+// @ts-expect-error a control set has no tag of its own; its members do
+const setWithTag: ControlDefinition = {
+ tag: 'div',
+ meta: { group: 'common', id: 'tagged-set' },
+ config: { label: 'Tagged' },
+ controlSet: { fields: [] },
+}
+
+const unnarrowed = (detail: BeforeAddControlSetDetail | BeforeAddFieldDetail) =>
+ // @ts-expect-error only a control set's data has fields; narrow on componentType first
+ detail.data.fields.length
+
+export { badLayout, fieldCount, setWithTag, unnarrowed }
diff --git a/src/types/formeo.d.ts b/src/types/formeo.d.ts
new file mode 100644
index 00000000..4771c501
--- /dev/null
+++ b/src/types/formeo.d.ts
@@ -0,0 +1,920 @@
+/**
+ * Type definitions for formeo.
+ *
+ * Hand-written. The runtime source of truth is src/lib/js/**; `npm run test:types` compiles
+ * src/types/formeo.test-d.ts against this file so the public API cannot drift silently.
+ * docs/typescript.md says what is typed and what is left out on purpose.
+ */
+
+// ---------------------------------------------------------------------------
+// formData (matches tools/formdata-schema.mjs)
+// ---------------------------------------------------------------------------
+
+/** 8-character hex short id (current editor output) or a full uuid (older saved forms). */
+export type ComponentId = string
+
+/** Dot address of a component or one of its properties, e.g. `fields.1c48584a` or `fields.1c48584a.options[0]`. */
+export type ComponentAddress = string
+
+export type ComponentType = 'stage' | 'row' | 'column' | 'field'
+
+/**
+ * Joins an `if` clause to the previous one (the first clause has none). The editor stores
+ * the symbols; the renderer also accepts the words. `&&` binds tighter than `||`.
+ */
+export type LogicalOperator = '&&' | '||' | 'and' | 'or'
+/** Symbols are what the editor stores; the renderer also accepts the names. */
+export type ComparisonOperator = '==' | '!=' | '⊃' | '!⊃' | 'equals' | 'notEquals' | 'contains' | 'notContains'
+export type AssignmentOperator = '=' | 'equals'
+/** `checked` is what pre-v5 editors saved; the renderer reads it like `value`. */
+export type ConditionSourceProperty = 'value' | 'isChecked' | 'isNotChecked' | 'isVisible' | 'isNotVisible' | 'checked'
+export type ConditionTargetProperty = 'value' | 'isChecked' | 'isNotChecked' | 'isVisible' | 'isNotVisible'
+
+/** Empty strings are what an unfinished condition row in the editor saves. */
+type Blank = ''
+
+// biome-ignore lint/suspicious/noExplicitAny: user callbacks declare their own parameter types
+type AnyFunction = (...args: any[]) => unknown
+
+export interface ConditionIf {
+ logical?: LogicalOperator
+ source?: ComponentAddress
+ sourceProperty?: ConditionSourceProperty | Blank
+ comparison?: ComparisonOperator | Blank
+ /** A literal value, or the address of another component when `targetProperty` is set. */
+ target?: string
+ targetProperty?: 'value' | Blank
+}
+
+export interface ConditionThen {
+ target?: ComponentAddress
+ targetProperty?: ConditionTargetProperty | Blank
+ assignment?: AssignmentOperator | Blank
+ value?: string
+}
+
+export interface Condition {
+ if?: ConditionIf[]
+ then?: ConditionThen[]
+}
+
+export interface AttrOption {
+ label: string
+ value: string
+ selected?: boolean
+}
+
+export type AttrValue = string | number | boolean | Record | AttrOption[] | AnyFunction
+export type HTMLAttributes = Record
+
+export interface FieldOption {
+ label: string
+ value?: string
+ selected?: boolean
+ checked?: boolean
+ [key: string]: unknown
+}
+
+/** Keys every component's data can have besides its id. (Interfaces, not `Omit`, which drops them with an index signature.) */
+export interface ComponentProps {
+ attrs?: HTMLAttributes
+ conditions?: Condition[]
+ [key: string]: unknown
+}
+
+export interface ComponentDataBase extends ComponentProps {
+ id: ComponentId
+}
+
+export interface StageData extends ComponentDataBase {
+ children: ComponentId[]
+ /** `title` names the page (editor `pages` option, renderer `pagination`). */
+ config?: { title?: string; [key: string]: unknown }
+}
+
+/** A row's data without its id and children, e.g. a control set's `row`. */
+export interface RowProps extends ComponentProps {
+ className?: string | string[]
+ config?: { fieldset?: boolean; legend?: string; inputGroup?: boolean; [key: string]: unknown }
+}
+
+export interface RowData extends RowProps {
+ id: ComponentId
+ children: ComponentId[]
+}
+
+export interface ColumnData extends ComponentDataBase {
+ children: ComponentId[]
+ className?: string | string[]
+ config?: { width?: string; [key: string]: unknown }
+}
+
+export interface FieldConfigData {
+ label?: string
+ editorLabel?: string
+ hideLabel?: boolean
+ labelAfter?: boolean
+ disableHtmlLabel?: boolean
+ editableContent?: boolean
+ helpText?: string
+ tooltip?: string
+ /** Id of the control this field was created from (`meta.id` of the control definition). */
+ controlId?: string
+ /** Attributes hidden from, and not addable in, the edit panel. Copied from the control definition. */
+ disabledAttrs?: string[]
+ /** Attributes that can't be removed, re-added or changed in the edit panel. Copied from the control definition. */
+ lockedAttrs?: string[]
+ [key: string]: unknown
+}
+
+/** A field's data without its id. */
+export interface FieldProps extends ComponentProps {
+ tag?: string
+ config?: FieldConfigData
+ meta?: { group?: string; icon?: string; id?: string; [key: string]: unknown }
+ content?: unknown
+ action?: Record
+ options?: FieldOption[]
+}
+
+export interface FieldData extends FieldProps {
+ id: ComponentId
+ tag: string
+}
+
+/** The canonical, flat form definition. Matches dist/formData_schema.json. */
+export interface FormeoFormData {
+ $schema?: string
+ id: ComponentId
+ stages: Record
+ rows: Record
+ columns: Record
+ fields: Record
+}
+
+/** @deprecated Use `FormeoFormData`. Kept because the README advertised it; it hides the DOM `FormData` where imported. */
+export type FormData = FormeoFormData
+
+/** What a new field starts from, before it gets an id: what a control adds, or an expanded control set member. */
+export interface NewFieldData extends FieldProps {
+ id?: ComponentId
+}
+
+// ---------------------------------------------------------------------------
+// Live components (what events and actions hand you)
+// ---------------------------------------------------------------------------
+
+export type ComponentEventHandler = (event: ComponentEventData) => void
+
+/**
+ * The public part of a stage, row, column or field in the editor. Everything else on the
+ * runtime object is internal and may change.
+ */
+export interface FormeoComponent {
+ readonly id: ComponentId
+ readonly name: ComponentType
+ readonly data: ComponentDataBase
+ readonly dom: HTMLElement
+ readonly parent: FormeoComponent | null
+ readonly children: FormeoComponent[]
+ /** `false` once the component has been removed. */
+ readonly isRegistered: boolean
+ get(path: string): unknown
+ set(path: string, value: unknown): unknown
+ /** Removes the property at `path`, or the component itself when called without one. */
+ remove(path?: string): unknown
+ /** `null` when this component's type can't have children (e.g. a field). */
+ addChild(childData?: Record | ComponentId, index?: number): FormeoComponent | null
+ addEventListener(eventName: string, handler: ComponentEventHandler): void
+ removeEventListener(eventName: string, handler: ComponentEventHandler): void
+}
+
+export interface Coords {
+ pageX: number
+ pageY: number
+}
+
+// ---------------------------------------------------------------------------
+// Controls
+// ---------------------------------------------------------------------------
+
+export interface ControlDependencies {
+ js?: string | string[]
+ css?: string | string[]
+}
+
+export interface ControlMeta {
+ /** Control group id, e.g. `common`, `html`, `layout` or one of `controls.groups`. */
+ group: string
+ /** Referenced by `config.controlId`, `elementOrder`, `disable.elements` and the renderer's `elements`. */
+ id: string
+ icon?: string
+}
+
+interface ControlDefinitionBase {
+ /** The control button's element id, used by `editor.controls.addElement(id)`. A uuid when left out. */
+ id?: string
+ config: FieldConfigData & { label: string }
+ meta: ControlMeta
+ dependencies?: ControlDependencies
+ /** DOM listeners for the control's button in the controls panel. */
+ controlAction?: Record
+ events?: Record
+}
+
+export interface FieldControlDefinition extends ControlDefinitionBase {
+ tag: string
+ attrs?: HTMLAttributes
+ options?: FieldOption[]
+ content?: unknown
+ children?: unknown
+ /** e.g. `onRender`, run on the field's preview; functions are not saved in formData. */
+ action?: Record
+ controlSet?: never
+ [key: string]: unknown
+}
+
+/** One field of a control set (#227). */
+export interface ControlSetMember extends FieldProps {
+ /** `meta.id` of a field control to start from; the member's other keys are merged over it (arrays replace). */
+ control?: string
+}
+
+export type ControlSetLayout = 'stacked' | 'columns'
+
+export interface ControlSet {
+ /** `stacked` (default): one column; `columns`: one column per field. */
+ layout?: ControlSetLayout
+ /** Data for the new row, e.g. `{ config: { fieldset: true, legend: 'Address' } }`. */
+ row?: RowProps
+ fields: ControlSetMember[]
+}
+
+/** A control that adds one new row holding several fields (#227). */
+export interface ControlSetDefinition extends ControlDefinitionBase {
+ controlSet: ControlSet
+ tag?: never
+}
+
+export type ControlDefinition = FieldControlDefinition | ControlSetDefinition
+
+/**
+ * A subclass of formeo's internal `Control` class. `Control` is not exported by the package,
+ * so this only applies to controls built from a source checkout.
+ */
+export interface ControlClass {
+ new (): {
+ id: string
+ controlData: Record & { meta: ControlMeta }
+ readonly dom: HTMLElement
+ promise(): Promise
+ }
+}
+
+export interface ControlGroup {
+ id: string
+ label: string
+ elementOrder?: string[]
+}
+
+export interface ControlsOptions {
+ /** Element, selector or jQuery object the controls panel is rendered into. Defaults to inside the editor. */
+ container?: ContainerOption
+ /** @default true */
+ sortable?: boolean
+ disable?: {
+ groups?: string[]
+ elements?: string[]
+ /** `true` hides both form action buttons; an array hides some, e.g. `['clearBtn']`. (`false` throws.) */
+ formActions?: true | Array<'clearBtn' | 'saveBtn'>
+ }
+ elements?: Array
+ elementOrder?: Record
+ groups?: ControlGroup[]
+ groupOrder?: string[]
+ ghostPreview?: boolean
+ panels?: { displayType?: 'slider' | 'tabbed' | 'auto' }
+}
+
+/** `editor.controls`, available once the editor is ready. */
+export interface FormeoControls {
+ /**
+ * Adds what a control creates to a stage (the active one by default), without the onBeforeAdd hook.
+ * `id` is the control's element id (`ControlDefinition.id`), not its `meta.id`.
+ */
+ addElement(id: string, stage?: FormeoComponent): FormeoComponent | undefined
+}
+
+// ---------------------------------------------------------------------------
+// config (per component type)
+// ---------------------------------------------------------------------------
+
+export type PanelName = 'attrs' | 'options' | 'conditions' | 'config' | (string & {})
+/** `page` is the row's "Move to page" button, added with the `pages` option. */
+export type ActionButtonName = 'move' | 'edit' | 'clone' | 'remove' | 'page' | (string & {})
+
+export interface ComponentEventData {
+ component: FormeoComponent
+ target: FormeoComponent
+ type: string
+ timestamp: number
+ [key: string]: unknown
+}
+
+export interface ComponentEvents {
+ onAdd?: ComponentEventHandler
+ /** Also called the legacy way, with `{ parent, child }` and no `component`/`target` — narrow first. */
+ onAddChild?: (event: ComponentEventData | { parent: FormeoComponent; child: FormeoComponent }) => void
+ onRemove?: ComponentEventHandler
+ /** Also called the legacy way, with the rendered element itself once it is in the page — narrow first. */
+ onRender?: (event: ComponentEventData | HTMLElement) => void
+ onClone?: ComponentEventHandler
+ onUpdate?: ComponentEventHandler
+}
+
+export interface ComponentConfig {
+ actionButtons?: { buttons?: Array>; disabled?: ActionButtonName[] }
+ panels?: {
+ disabled?: PanelName[]
+ order?: PanelName[]
+ attrs?: {
+ /** Attribute names hidden from the attrs panel. Combined with control `disabledAttrs`. */
+ disabled?: string[]
+ /** Attribute names that can't be removed or changed. Combined with control `lockedAttrs`. */
+ locked?: string[]
+ hideDisabled?: boolean
+ }
+ [panel: string]: unknown
+ }
+ /** Full property paths, e.g. `['attrs.type']`. */
+ disabled?: string[]
+ /** Full property paths, e.g. `['attrs.required']`. */
+ locked?: string[]
+ events?: ComponentEvents
+ [key: string]: unknown
+}
+
+export interface FieldComponentConfig extends ComponentConfig {
+ label?: { disableHTML?: boolean }
+ /** Offer a dropdown for an attribute instead of a free text input. */
+ attrs?: Record
+ config?: Partial
+}
+
+/** `all` applies to every component of the type; any other key is a control id (fields) or a component id. */
+export type ScopedConfig = { all?: T } & { [controlOrComponentId: string]: T | undefined }
+
+export interface EditorConfig {
+ stages?: ScopedConfig
+ rows?: ScopedConfig
+ columns?: ScopedConfig
+ fields?: ScopedConfig
+}
+
+// ---------------------------------------------------------------------------
+// Editor events
+// ---------------------------------------------------------------------------
+
+export interface FormeoEvent {
+ timeStamp: number
+ type: string
+ detail: TDetail
+}
+
+/**
+ * A component-type data store (`stages`, `rows`, `columns` or `fields`), as opposed to a single component. A
+ * page reorder's `formeoUpdated`/`onUpdate` detail sets `entity` to this (the stages store itself), not a
+ * {@link FormeoComponent}.
+ */
+export interface ComponentStore {
+ readonly name: 'stages' | 'rows' | 'columns' | 'fields'
+ readonly data: Record
+ readonly size: number
+}
+
+/** `detail` of the added, removed and updated events; which keys are set depends on the event. */
+export interface FormeoChangeDetail {
+ entity?: FormeoComponent | ComponentStore
+ componentId?: ComponentId
+ componentType?: ComponentType
+ parent?: FormeoComponent
+ dataPath?: string
+ changePath?: string
+ value?: unknown
+ previousValue?: unknown
+ /** `'reordered'` is a page (stage) reorder; `value`/`previousValue` are the new/old stage id order. */
+ changeType?: 'added' | 'removed' | 'changed' | 'unchanged' | 'reordered'
+ data?: unknown
+ [key: string]: unknown
+}
+
+export interface FormeoSaveEvent {
+ timeStamp: number
+ type: string
+ formData: FormeoFormData
+}
+
+export interface ConfirmClearAllDetail {
+ confirmationMessage: string
+ /** Call it to clear the form. */
+ clearAllAction: (event?: unknown) => void
+ btnCoords: Coords
+}
+
+export interface ConfirmClearAllEvent extends ConfirmClearAllDetail {
+ timeStamp: number
+ type: string
+}
+
+export interface EditorPageChangeDetail {
+ page: number
+ previousPage: number
+ stageId: ComponentId
+ previousStageId: ComponentId
+}
+
+export interface EditToggleDetail {
+ component: FormeoComponent
+ componentType: ComponentType
+ componentId: ComponentId
+}
+
+interface BeforeAddPlacement {
+ /** The control's `meta.id`, e.g. `text-input`. */
+ controlId: string
+ /** The page (stage), row or column it goes into. */
+ parent: FormeoComponent
+ index: number
+ addedVia: 'click' | 'dragDrop'
+}
+
+export interface BeforeAddFieldDetail extends BeforeAddPlacement {
+ componentType: 'field'
+ /** What the new field starts from. Treat it as read-only. */
+ data: NewFieldData
+}
+
+export interface BeforeAddLayoutDetail extends BeforeAddPlacement {
+ componentType: 'row' | 'column'
+ data: Record
+}
+
+export interface ControlSetData {
+ layout: ControlSetLayout
+ row: Record
+ fields: NewFieldData[]
+}
+
+export interface BeforeAddControlSetDetail extends BeforeAddPlacement {
+ componentType: 'controlSet'
+ /** The expanded set. Treat it as read-only. */
+ data: ControlSetData
+}
+
+/** The + page tab (editor `pages` option). */
+export interface BeforeAddPageDetail {
+ componentType: 'stage'
+ index: number
+}
+
+export type BeforeAddDetail =
+ | BeforeAddFieldDetail
+ | BeforeAddLayoutDetail
+ | BeforeAddControlSetDetail
+ | BeforeAddPageDetail
+
+export interface BeforeRemoveComponentDetail {
+ component: FormeoComponent
+ componentType: 'row' | 'column' | 'field'
+ componentId: ComponentId
+}
+
+export interface BeforeRemovePageDetail {
+ component: FormeoComponent
+ componentType: 'stage'
+ componentId: ComponentId
+ index: number
+ title: string
+ isEmpty: boolean
+}
+
+export type BeforeRemoveDetail = BeforeRemoveComponentDetail | BeforeRemovePageDetail
+
+export interface BeforeCloneDetail {
+ component: FormeoComponent
+ componentType: 'row' | 'column' | 'field'
+ componentId: ComponentId
+ parent: FormeoComponent
+}
+
+export interface BeforeSaveDetail {
+ /** The formData an allowed save saves. */
+ formData: FormeoFormData
+}
+
+/** What an `onBefore*` callback receives. */
+export interface BeforeHookEvent extends FormeoEvent {
+ preventDefault(): void
+ readonly defaultPrevented: boolean
+}
+
+/**
+ * `false` (or a Promise resolving to `false`) cancels. A Promise holds the change until it settles;
+ * a rejection cancels. Anything else lets the change happen.
+ */
+// biome-ignore lint/suspicious/noConfusingVoidType: a callback without a return statement returns void
+export type BeforeHookResult = boolean | undefined | void | Promise
+
+export type BeforeHook = (event: BeforeHookEvent) => BeforeHookResult
+
+export interface FormeoEvents {
+ /** Log every event to the console. */
+ debug?: boolean
+ /** Whether formeo's DOM events bubble. @default true */
+ bubbles?: boolean
+ /** Called with the editor instance each time it renders. */
+ formeoLoaded?: (editor: FormeoEditor) => void
+ onAdd?: (event: FormeoEvent) => void
+ onAddStage?: (event: FormeoEvent) => void
+ onAddRow?: (event: FormeoEvent) => void
+ onAddColumn?: (event: FormeoEvent) => void
+ onAddField?: (event: FormeoEvent) => void
+ onRemove?: (event: FormeoEvent) => void
+ onRemoveStage?: (event: FormeoEvent) => void
+ onRemoveRow?: (event: FormeoEvent) => void
+ onRemoveColumn?: (event: FormeoEvent) => void
+ onRemoveField?: (event: FormeoEvent) => void
+ /** Throttled; `detail` is the whole formData. */
+ onChange?: (event: FormeoEvent) => void
+ /** Throttled with the whole formData as `detail`, and once per component update with that change. */
+ onUpdate?: (event: FormeoEvent) => void
+ onUpdateStage?: (event: FormeoEvent) => void
+ onUpdateRow?: (event: FormeoEvent) => void
+ onUpdateColumn?: (event: FormeoEvent) => void
+ onUpdateField?: (event: FormeoEvent) => void
+ onSave?: (event: FormeoSaveEvent) => void
+ /** The active page tab changed (editor `pages` option). */
+ onPageChange?: (event: FormeoEvent) => void
+ onBeforeAdd?: BeforeHook
+ onBeforeRemove?: BeforeHook
+ onBeforeClone?: BeforeHook
+ onBeforeSave?: BeforeHook
+ onEditOpen?: (event: FormeoEvent) => void
+ onEditClose?: (event: FormeoEvent) => void
+ confirmClearAll?: (event: ConfirmClearAllEvent) => void
+}
+
+/**
+ * Custom DOM events formeo dispatches; they bubble to `document` unless `events.bubbles` is `false`.
+ * `formeoOnRender` and `formeoConditionUpdated` (and the editor's `events.onRender`) are documented but never
+ * dispatched, so they are left out until they are.
+ */
+export interface FormeoDomEventMap {
+ formeoLoaded: CustomEvent<{ formeo: FormeoEditor }>
+ formeoSaved: CustomEvent<{ formData: FormeoFormData }>
+ formeoUpdated: CustomEvent
+ formeoChanged: CustomEvent
+ formeoUpdatedStage: CustomEvent
+ formeoUpdatedRow: CustomEvent
+ formeoUpdatedColumn: CustomEvent
+ formeoUpdatedField: CustomEvent
+ formeoAddedStage: CustomEvent
+ formeoAddedRow: CustomEvent
+ formeoAddedColumn: CustomEvent
+ formeoAddedField: CustomEvent
+ formeoRemovedStage: CustomEvent
+ formeoRemovedRow: CustomEvent
+ formeoRemovedColumn: CustomEvent
+ formeoRemovedField: CustomEvent
+ formeoCleared: CustomEvent
+ formeoPageChanged: CustomEvent
+ /** Cancelable: `preventDefault()` in a listener cancels, synchronously only. */
+ formeoBeforeAdd: CustomEvent
+ formeoBeforeRemove: CustomEvent
+ formeoBeforeClone: CustomEvent
+ formeoBeforeSave: CustomEvent
+ formeoEditOpened: CustomEvent
+ formeoEditClosed: CustomEvent
+ confirmClearAll: CustomEvent
+}
+
+export type FormeoDomEventName = keyof FormeoDomEventMap
+
+// ---------------------------------------------------------------------------
+// Editor actions
+// ---------------------------------------------------------------------------
+
+export interface AddAttrActionEvent {
+ btnCoords: Coords
+ /** Adds the attribute. Pass strings (`'true'`/`'false'` become booleans). */
+ addAction: (attr: string, value?: AttrValue) => void
+ /** Takes a full path, e.g. `attrs.type`. */
+ isDisabled: (propName: string) => boolean
+ isLocked: (propName: string) => boolean
+ message: { attr: string; value: string }
+}
+
+export interface AddOptionActionEvent {
+ btnCoords: Coords
+ addAction: () => void
+}
+
+export interface AddConditionActionEvent {
+ btnCoords: Coords
+ /** The empty condition that `addAction(event)` adds. */
+ template: Condition
+ addAction: (event: { template: Condition }) => void
+}
+
+export interface AddConfigActionEvent {
+ btnCoords: Coords
+ /** Opens the dialog that picks a configuration key. */
+ addAction: (event?: unknown) => void
+}
+
+export type RemovableItemType = 'attrs' | 'options' | 'conditions'
+
+/** One attribute, option or condition removed with its × button. */
+export interface RemoveItemActionEvent {
+ type: T
+ /** The item's path, e.g. `attrs.required` or `options[1]`. */
+ itemKey: string
+ component: FormeoComponent
+ isClearAll: false
+ /** Removes it. Only the first call does anything. */
+ removeAction: () => void
+}
+
+/** "Clear All" in the conditions panel. */
+export interface ClearAllActionEvent {
+ type: T
+ isClearAll: true
+ removeAction: () => void
+}
+
+export type RemoveItemsActionEvent = RemoveItemActionEvent | ClearAllActionEvent
+
+export interface RemoveComponentActionEvent {
+ component: FormeoComponent
+ componentType: 'row' | 'column' | 'field'
+ componentId: ComponentId
+ /** Removes the component. Not calling it cancels the removal; only the first call does anything. */
+ removeAction: () => void
+}
+
+export interface RemovePageActionEvent {
+ stage: FormeoComponent
+ stageId: ComponentId
+ index: number
+ /** Falls back to "Page {n}" like its tab. */
+ title: string
+ isEmpty: boolean
+ removeAction: () => void
+}
+
+export interface ClickBtnActionEvent {
+ /** What the default handler calls; a no-op for the Save button. */
+ action: () => void
+ coords: Coords
+ message: string
+ button: HTMLElement
+}
+
+export interface FormeoActions {
+ add?: {
+ attr?: (event: AddAttrActionEvent) => void
+ option?: (event: AddOptionActionEvent) => void
+ condition?: (event: AddConditionActionEvent) => void
+ config?: (event: AddConfigActionEvent) => void
+ }
+ remove?: {
+ attrs?: (event: RemoveItemsActionEvent<'attrs'>) => void
+ options?: (event: RemoveItemsActionEvent<'options'>) => void
+ conditions?: (event: RemoveItemsActionEvent<'conditions'>) => void
+ /** A row, column or field's × button, after `onBeforeRemove`. */
+ component?: (event: RemoveComponentActionEvent) => void
+ /** A page's × or Delete (editor `pages` option), after `onBeforeRemove`. */
+ page?: (event: RemovePageActionEvent) => void
+ }
+ click?: {
+ btn?: (event: ClickBtnActionEvent) => void
+ }
+ save?: {
+ form?: (formData: FormeoFormData) => unknown
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Editor
+// ---------------------------------------------------------------------------
+
+/** A jQuery object (or anything array-like with a `jquery` key); its first element is used. */
+export interface JQueryLike {
+ readonly jquery: string
+ readonly [index: number]: Element | undefined
+}
+
+/**
+ * A selector, an element, or a jQuery object. `dom.resolveContainer()` accepts any `Element`, not just
+ * `HTMLElement` (e.g. an SVGElement), and passes `null`/`undefined` straight through with no fallback of its own;
+ * the editor then treats a container that resolves to nothing as "not attached" rather than throwing. The renderer
+ * does the same when it is created, but `render()` throws if the container never resolved.
+ */
+export type ContainerOption = string | Element | JQueryLike | null
+
+export interface I18nOptions {
+ /** Where language files are fetched from. */
+ location?: string
+ extension?: string
+ /** Starting locale; a language picked with `editor.i18n.setLang()` wins for the tab. */
+ locale?: string
+ langs?: string[]
+ override?: Record>
+ preloaded?: Record>
+}
+
+export interface FormeoEditorOptions {
+ /** Where the editor is added. Without it the editor is built but not attached. */
+ editorContainer?: ContainerOption
+ formData?: FormeoFormData | string
+ /** URL of a custom sprite. `null` uses the bundled sprite. */
+ svgSprite?: string | null
+ /** URL of the formeo stylesheet to load. `null` loads none. */
+ style?: string | null
+ iconFont?: 'glyphicons' | 'font-awesome' | 'fontello' | null
+ /** `true` saves to the `formeo-formData` key; a string is the key (give each editor on a page its own). */
+ sessionStorage?: boolean | string
+ stickyControls?: boolean
+ controlOnLeft?: boolean
+ /** Page tabs, one per stage. */
+ pages?: boolean
+ /** @deprecated */
+ allowEdit?: boolean
+ dataType?: 'json'
+ debug?: boolean
+ config?: EditorConfig
+ events?: FormeoEvents
+ actions?: FormeoActions
+ controls?: ControlsOptions
+ i18n?: I18nOptions
+ onLoad?: (editor: FormeoEditor) => void
+}
+
+/** @deprecated Use `FormeoEditorOptions`. Kept because the README advertised it. */
+export type FormeoOptions = FormeoEditorOptions
+
+export type EditorInitState = 'created' | 'loading' | 'initializing' | 'ready' | 'error' | 'destroyed'
+
+/** `editor.pages` with the `pages` option. The rest of the runtime object is internal. */
+export interface EditorPages {
+ /** 0-based index of the active page. */
+ readonly index: number
+ readonly count: number
+ activate(stageIdOrIndex: ComponentId | number, options?: { focus?: boolean }): void
+ /** Appends a page, activates it and focuses its tab. Runs no before hook. */
+ add(options?: { title?: string }): FormeoComponent
+ destroy(): void
+}
+
+export declare class FormeoEditor {
+ constructor(options?: FormeoEditorOptions, formData?: FormeoFormData | string)
+ /** Current form definition. Assigning reloads the editor. Empty once destroyed. */
+ get formData(): FormeoFormData
+ set formData(data: FormeoFormData | string)
+ /** formData serialised with a `$schema` reference. */
+ readonly json: string
+ readonly initState: EditorInitState
+ readonly isReady: boolean
+ readonly isDestroyed: boolean
+ editorContainer?: HTMLElement | null
+ /** `null` unless the `pages` option is on. */
+ readonly pages: EditorPages | null
+ /** Available once ready. */
+ controls?: FormeoControls | null
+ /** Available once ready. */
+ i18n?: { setLang: (locale: string) => Promise }
+ loadData(data?: FormeoFormData | string): void
+ load(formData?: FormeoFormData | string, opts?: FormeoEditorOptions): void
+ clear(): void
+ render(): void
+ init(): Promise
+ /** Resolves once ready; rejects if initialization fails or the editor is destroyed first. */
+ whenReady(): Promise
+ /** Removes the editor and releases its resources. Safe to call more than once. */
+ destroy(): void
+}
+
+// ---------------------------------------------------------------------------
+// Renderer
+// ---------------------------------------------------------------------------
+
+export interface RendererElementDefinition {
+ /** e.g. `onRender`, run once the rendered field is in the page. */
+ action?: Record void>
+ dependencies?: ControlDependencies
+}
+
+export interface RendererPaginationLabels {
+ previous?: string
+ next?: string
+ /** `{n}` is the 1-based page number. */
+ page?: string
+ submit?: string
+ tablist?: string
+ steps?: string
+ navigation?: string
+ /** `{title}`, `{n}` and `{count}` are replaced. */
+ status?: string
+}
+
+export interface RendererPaginationOptions {
+ type: 'tabs' | 'wizard'
+ /** Wizard only: the step list. @default true */
+ progress?: boolean
+ /** Add a Submit button. @default false */
+ submit?: boolean
+ /** Page title headings: `true` for ``, or a level from 2 to 6. @default false */
+ heading?: boolean | 2 | 3 | 4 | 5 | 6
+ labels?: RendererPaginationLabels
+}
+
+/** Values keyed by input name; repeated names (checkbox groups, multiple selects) become arrays. */
+export type UserData = Record
+
+export interface RendererPageChangeDetail {
+ page: number
+ previousPage: number
+ stageId: ComponentId | null
+ previousStageId: ComponentId | null
+ form: HTMLFormElement
+ renderer: FormeoRenderer
+}
+
+export interface FormeoRendererEvents {
+ /** After `render()` attached the form. */
+ onRender?: (event: { form: HTMLFormElement; renderer: FormeoRenderer; formData: FormeoFormData }) => void
+ /** Every `input` event in the form. */
+ onChange?: (event: { event: Event; target: EventTarget | null; form: HTMLFormElement; userData: UserData }) => void
+ /** The form's native `submit` event; formeo does not call `preventDefault()`. */
+ onSubmit?: (event: { event: SubmitEvent; form: HTMLFormElement; userData: UserData }) => void
+ onPageChange?: (event: RendererPageChangeDetail) => void
+}
+
+export interface FormeoRendererOptions {
+ /** Required by `render()`; `getRenderedForm()` and `html` work without it. */
+ renderContainer?: ContainerOption
+ formData?: FormeoFormData | string
+ /** Keyed by control id (`config.controlId` / `meta.id`). */
+ elements?: Record
+ /** Spread onto the rendered `