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 `
`, e.g. `{ attrs: { novalidate: true } }`. */ + config?: { attrs?: HTMLAttributes; action?: Record; [key: string]: unknown } + /** Show the stages one at a time. */ + pagination?: 'tabs' | 'wizard' | RendererPaginationOptions + events?: FormeoRendererEvents +} + +export interface UserFormDataEntry { + key: string + value: FormDataEntryValue | FormDataEntryValue[] + label: string +} + +export declare class FormeoRenderer { + constructor(options?: FormeoRendererOptions, formData?: FormeoFormData | string) + container?: HTMLElement | null + get formData(): FormeoFormData + set formData(data: FormeoFormData | string) + /** Setting fills the rendered form; names with no field are skipped with a console warning. */ + get userData(): UserData + set userData(data: UserData) + readonly userFormData: UserFormDataEntry[] + /** Outer HTML of the rendered form. */ + readonly html: string + /** 0-based index of the page on show; setting it shows that page without validating. */ + get page(): number + set page(index: number) + readonly pageCount: number + /** Rendered components' data, keyed by id. */ + readonly components: Record + /** Throws without `renderContainer`. */ + render(formData?: FormeoFormData | string): void + getRenderedForm(formData?: FormeoFormData | string): HTMLFormElement + /** Removes the rendered form and stops pagination; `render()` works again afterwards. */ + destroy(): void +} + +// --------------------------------------------------------------------------- +// UMD globals and DOM events +// --------------------------------------------------------------------------- + +declare global { + interface Window { + FormeoEditor: typeof FormeoEditor + FormeoRenderer: typeof FormeoRenderer + } + // Formeo events bubble to `document` unless `events.bubbles` is `false`, but they're dispatched on the + // editor's own elements first, so both event maps need the same members (docs/options/events/README.md + // recommends listening on the editor's container). + interface DocumentEventMap extends FormeoDomEventMap {} + interface HTMLElementEventMap extends FormeoDomEventMap {} +} diff --git a/src/types/formeo.test-d.ts b/src/types/formeo.test-d.ts new file mode 100644 index 00000000..c0e4db76 --- /dev/null +++ b/src/types/formeo.test-d.ts @@ -0,0 +1,298 @@ +// Compile-only usage checks for src/types/formeo.d.ts. Run with `npm run test:types`. +// Every `@ts-expect-error` below must stay an error; if one stops erroring, tsc fails with TS2578. +import { + type BeforeAddDetail, + type ComponentEventData, + type ComponentEvents, + type ControlDefinition, + type FormData, + type FormeoChangeDetail, + type FormeoComponent, + FormeoEditor, + type FormeoEditorOptions, + type FormeoFormData, + type FormeoOptions, + FormeoRenderer, + type FormeoRendererOptions, + type LogicalOperator, + type RemoveItemsActionEvent, + type UserData, +} from 'formeo' + +const formData: FormeoFormData = { + id: 'a95d0c69', + stages: { + ff163056: { + id: 'ff163056', + children: ['c5060f33'], + config: { title: 'Contact' }, + conditions: [ + { + if: [ + { + source: 'fields.056be3d7', + sourceProperty: 'value', + comparison: '==', + target: 'yes', + targetProperty: 'value', + }, + { logical: '&&', source: 'fields.6c67ea4a.options[0]', sourceProperty: 'isChecked' }, + ], + then: [{ target: 'fields.7e3298ba', targetProperty: 'isNotVisible', assignment: '', value: '' }], + }, + ], + }, + }, + rows: { c5060f33: { id: 'c5060f33', children: ['2474fbd2'], config: { fieldset: false } } }, + columns: { '2474fbd2': { id: '2474fbd2', children: ['056be3d7'], config: { width: '100%' } } }, + fields: { + '056be3d7': { + id: '056be3d7', + tag: 'input', + attrs: { type: 'text', required: false, className: '' }, + config: { label: 'Input One', controlId: 'text-input' }, + }, + }, +} + +const emailControl: ControlDefinition = { + id: 'email-control', + tag: 'input', + attrs: { type: 'email', required: true, className: 'custom-email' }, + config: { label: 'Email', disabledAttrs: ['type'], lockedAttrs: ['required', 'className'] }, + meta: { group: 'common', id: 'email', icon: '@' }, + dependencies: { js: ['https://cdn.example.com/a.js'], css: 'https://cdn.example.com/a.css' }, + action: { onRender: (elem: HTMLElement) => elem.id }, +} + +// onRender and onAddChild are also called the legacy way; narrow before reading `component`/`parent`/`child`. +const componentEvents: ComponentEvents = { + onRender: evt => { + if (evt instanceof HTMLElement) { + evt.classList.add('rendered') + } else { + evt.component.id + } + }, + onAddChild: evt => { + if ('component' in evt) { + evt.component.id + const grandchild = evt.component.addChild() + // @ts-expect-error addChild returns null when the component can't have children (e.g. a field); narrow first + grandchild.id + } else { + evt.parent.id + evt.child.id + } + }, +} + +// A page reorder's `formeoUpdated`/`onUpdate` detail: `entity` is the stages store, not a FormeoComponent. +const describeReorder = (detail: FormeoChangeDetail) => { + if (detail.changeType === 'reordered') { + return detail.value + } +} + +const describeAdd = (detail: BeforeAddDetail): string => { + switch (detail.componentType) { + case 'field': + return detail.data.tag ?? detail.controlId + case 'row': + case 'column': + return `${detail.addedVia} ${detail.parent.id} ${detail.index}` + case 'stage': + return `page ${detail.index}` + case 'controlSet': + return `${detail.data.layout} ${detail.data.fields.length}` + } +} + +const options: FormeoEditorOptions = { + editorContainer: '#formeo-editor', + sessionStorage: 'orders-form', + pages: true, + svgSprite: null, + controls: { + container: document.querySelector('.controls'), + elements: [emailControl], + disable: { formActions: ['clearBtn'] }, + panels: { displayType: 'tabbed' }, + }, + config: { + fields: { + all: { panels: { attrs: { locked: ['required'] } } }, + 'text-input': { attrs: { type: [{ label: 'text', value: 'text' }] } }, + }, + rows: { all: { actionButtons: { disabled: ['page'] } } }, + }, + events: { + onSave: ({ formData }) => formData.fields, + onChange: evt => evt.detail.stages, + onUpdateField: ({ detail }) => detail.changePath, + formeoLoaded: editor => editor.formData, + onPageChange: ({ detail }) => detail.page - detail.previousPage, + onAddStage: ({ detail }) => detail.componentId, + onBeforeAdd: ({ detail }) => describeAdd(detail) !== '', + onBeforeRemove: async ({ detail }) => { + if (detail.componentType === 'stage') { + return detail.isEmpty || window.confirm(`Remove ${detail.title}?`) + } + const response = await fetch(`/api/fields/${detail.componentId}/usage`) + return !(await response.json()).inUse + }, + onBeforeClone: evt => { + if (evt.detail.componentType === 'row') { + evt.preventDefault() + } + }, + onBeforeSave: ({ detail }) => Object.keys(detail.formData.fields).length > 0, + onEditOpen: ({ detail }) => detail.component.dom.focus(), + onEditClose: ({ detail }) => detail.componentType, + confirmClearAll: evt => evt.clearAllAction(), + }, + actions: { + add: { + attr: evt => (evt.isDisabled('attrs.data-x') ? undefined : evt.addAction('data-x', '1')), + condition: evt => evt.addAction(evt), + }, + remove: { + component: evt => window.confirm(`Delete this ${evt.componentType}?`) && evt.removeAction(), + conditions: evt => (evt.isClearAll ? evt.removeAction() : console.log(evt.itemKey, evt.component.id)), + page: evt => (evt.isEmpty || window.confirm(evt.title)) && evt.removeAction(), + }, + click: { btn: evt => evt.action() }, + save: { form: data => data.id }, + }, + i18n: { locale: 'de-DE', location: 'https://example.com/lang/' }, + onLoad: editor => editor.isReady, +} + +// README-advertised aliases still work +const legacyOptions: FormeoOptions = options +const legacyData: FormData = formData + +const editor = new FormeoEditor(legacyOptions, legacyData) +const current: FormeoFormData = editor.formData +editor.formData = JSON.stringify(current) +const json: string = editor.json +editor.clear() +void editor.whenReady().then(ready => ready.i18n?.setLang('en-US')) +editor.pages?.activate(1, { focus: true }) +const newPage = editor.pages?.add({ title: 'Payment' }) +editor.controls?.addElement('email-control') +if (editor.initState === 'destroyed' || editor.isDestroyed) { + editor.destroy() +} +const bare = new FormeoEditor() +const jquery = new FormeoEditor({ editorContainer: { jquery: '3.7.1', 0: document.body } }) +// document.querySelector() returns Element | null; the runtime accepts any element as a container. +const queriedContainer: Element | null = document.querySelector('#x') +const queried = new FormeoEditor({ editorContainer: queriedContainer }) + +document.addEventListener('formeoBeforeRemove', evt => { + if (evt.detail.componentType === 'row') { + evt.preventDefault() + } +}) +document.addEventListener('formeoEditOpened', evt => evt.detail.componentId) +// Formeo DOM events bubble on the editor's own elements too, not just `document`. +editor.editorContainer?.addEventListener('formeoEditOpened', e => e.detail.componentId) + +const queriedRenderContainer: Element | null = document.querySelector('#formeo-renderer') +const rendererOptions: FormeoRendererOptions = { + renderContainer: queriedRenderContainer, + formData, + pagination: { type: 'wizard', heading: 3, submit: true, labels: { next: 'Weiter', status: '{title} ({n}/{count})' } }, + elements: { email: { action: { onRender: elem => elem.focus() } } }, + config: { attrs: { novalidate: true } }, + events: { + onRender: ({ form }) => form.id, + onChange: ({ userData }) => userData.email, + onSubmit: ({ event, userData }) => { + event.preventDefault() + return userData + }, + onPageChange: ({ page, stageId }) => `${page}:${stageId ?? ''}`, + }, +} +const renderer = new FormeoRenderer(rendererOptions) +renderer.render() +renderer.userData = { name: 'Ada', hobbies: ['a', 'b'] } +const values: UserData = renderer.userData +const html: string = renderer.html +renderer.page = renderer.pageCount - 1 +renderer.destroy() +const headless = new FormeoRenderer().getRenderedForm(formData) + +const fromGlobal = new window.FormeoEditor({ editorContainer: document.body }) + +const and: LogicalOperator = '&&' + +// --- negative cases: these must stay type errors --------------------------------- + +// @ts-expect-error `logical` is case-sensitive +const badLogical: LogicalOperator = 'AND' + +// @ts-expect-error formData ids map to objects with children +const badStage: FormeoFormData = { id: 'x', stages: { a: { id: 'a' } }, rows: {}, columns: {}, fields: {} } + +// @ts-expect-error unknown editor option names are rejected (catches typos such as `container`) +const typo: FormeoEditorOptions = { container: '#x' } + +// @ts-expect-error the renderer paginates as tabs or a wizard only +const badPagination: FormeoRendererOptions = { pagination: 'accordion' } + +// @ts-expect-error initState is read-only +editor.initState = 'ready' + +// @ts-expect-error `formActions: false` throws at runtime (controls/index.js calls .includes on it); leave it out +const badFormActions: FormeoEditorOptions = { controls: { disable: { formActions: false } } } + +const badHook: FormeoEditorOptions = { + events: { + // @ts-expect-error a before hook returns a boolean, nothing, or a Promise of one; a string is a mistake + onBeforeSave: () => 'no', + }, +} + +const unnarrowed = (detail: BeforeAddDetail) => + // @ts-expect-error only a control's detail has a controlId; a page's (componentType 'stage') does not + detail.controlId + +const clearAll = (evt: RemoveItemsActionEvent<'conditions'>) => + // @ts-expect-error "Clear All" has no itemKey, so check isClearAll first + evt.itemKey + +const unnarrowedRender = (evt: ComponentEventData | HTMLElement) => + // @ts-expect-error `evt` may be the bare element from the legacy onRender call; narrow with `instanceof HTMLElement` first + evt.component + +const unnarrowedAddChild = (evt: ComponentEventData | { parent: FormeoComponent; child: FormeoComponent }) => + // @ts-expect-error `evt` may be the legacy `{ parent, child }` payload; narrow with `'component' in evt` first + evt.component + +export { + and, + badFormActions, + badHook, + badLogical, + badPagination, + badStage, + bare, + clearAll, + componentEvents, + describeReorder, + fromGlobal, + headless, + html, + jquery, + json, + newPage, + queried, + typo, + unnarrowed, + unnarrowedAddChild, + unnarrowedRender, + values, +} diff --git a/src/types/tsconfig.json b/src/types/tsconfig.json new file mode 100644 index 00000000..1806f4e0 --- /dev/null +++ b/src/types/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "skipLibCheck": false, + "verbatimModuleSyntax": true, + "paths": { "formeo": ["./formeo.d.ts"] } + }, + "include": ["*.test-d.ts"] +} diff --git a/tools/build-types.mjs b/tools/build-types.mjs new file mode 100644 index 00000000..57fde129 --- /dev/null +++ b/tools/build-types.mjs @@ -0,0 +1,27 @@ +import { copyFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const root = fileURLToPath(new URL('../', import.meta.url)) + +/** Hand-written declarations; the single source for both published copies. */ +export const TYPES_SOURCE = join(root, 'src/types/formeo.d.ts') + +/** + * Copy the declarations next to the bundles. `.d.ts` describes the ES and UMD builds and `.d.cts` + * the CommonJS build (`dist/formeo.cjs`); TypeScript picks the one matching the resolved file's format. + * @param {string} [outDir] + * @return {string[]} written file paths + */ +export function buildTypes(outDir = join(root, 'dist')) { + mkdirSync(outDir, { recursive: true }) + const written = ['formeo.d.ts', 'formeo.d.cts'].map(name => join(outDir, name)) + for (const file of written) { + copyFileSync(TYPES_SOURCE, file) + } + return written +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + console.log(`types: ${buildTypes().join(', ')}`) +} diff --git a/tools/dist.test.mjs b/tools/dist.test.mjs index cd164828..c3f02eb1 100644 --- a/tools/dist.test.mjs +++ b/tools/dist.test.mjs @@ -3,7 +3,7 @@ // node_modules/formeo, so package.json main/exports are what resolve it. Run after `npm run build:lib`. import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { after, before, describe, test } from 'node:test' @@ -45,7 +45,7 @@ describe('built package in plain Node', () => { }) test('every entry point package.json names exists', () => { - const targets = [pkg.main, pkg.module, pkg.unpkg, ...Object.values(pkg.exports).flatMap(Object.values)] + const targets = [pkg.main, pkg.module, pkg.unpkg, pkg.types, ...exportTargets(pkg.exports)].filter(Boolean) for (const target of new Set(targets)) { assert.ok(existsSync(join(root, target)), target) } @@ -53,6 +53,48 @@ describe('built package in plain Node', () => { test('the CommonJS entry is a .cjs file', () => { assert.match(pkg.main, /\.cjs$/) - assert.match(pkg.exports['.'].require, /\.cjs$/) + assert.match(pkg.exports['.'].require.default, /\.cjs$/) + }) + + test('every entry point has built type declarations', () => { + assert.equal(pkg.types, './dist/formeo.d.ts') + for (const [condition, target] of Object.entries(pkg.exports['.'])) { + assert.ok(target.types, `exports["."].${condition} has no types`) + assert.ok(existsSync(join(root, target.types)), target.types) + } + assert.match(pkg.exports['.'].require.types, /\.d\.cts$/) + }) + + test('TypeScript finds the declarations in every module resolution mode', () => { + const tsc = join(root, 'node_modules', 'typescript', 'bin', 'tsc') + const source = [ + "import { FormeoEditor, type FormeoFormData } from 'formeo'", + 'const formData: FormeoFormData = new FormeoEditor().formData', + 'export { formData }', + '', + ].join('\n') + for (const file of ['esm.mts', 'cjs.cts', 'app.ts']) { + writeFileSync(join(consumer, file), source) + } + const modes = [ + ['--module', 'nodenext', '--moduleResolution', 'nodenext', 'esm.mts', 'cjs.cts'], + ['--module', 'esnext', '--moduleResolution', 'bundler', 'app.ts'], + ] + for (const mode of modes) { + try { + run([tsc, '--noEmit', '--strict', '--lib', 'es2022,dom', ...mode]) + } catch (error) { + assert.fail(`tsc ${mode.join(' ')}\n${error.stdout}`) + } + } }) }) + +/** + * Every file path in an exports tree; a condition's value is a path or another condition object. + * @param {string|Object} value + * @return {string[]} + */ +function exportTargets(value) { + return typeof value === 'string' ? [value] : Object.values(value).flatMap(exportTargets) +}