From 7440a1723716662e1b5c4f9038638b1e38cc27a9 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 16:39:09 +0100 Subject: [PATCH 01/10] feat(types): add TypeScript definitions for the public API Hand-written types for FormeoEditor and FormeoRenderer, every option, event, before hook, action and DOM event, and the formData shape. npm run test:types compiles a usage fixture against them with tsc --noEmit. Refs #184 --- package-lock.json | 375 +++++++++++++++- package.json | 2 + src/types/formeo.d.ts | 857 +++++++++++++++++++++++++++++++++++++ src/types/formeo.test-d.ts | 244 +++++++++++ src/types/tsconfig.json | 15 + 5 files changed, 1486 insertions(+), 7 deletions(-) create mode 100644 src/types/formeo.d.ts create mode 100644 src/types/formeo.test-d.ts create mode 100644 src/types/tsconfig.json 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..ed583d25 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,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", @@ -119,6 +120,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/types/formeo.d.ts b/src/types/formeo.d.ts new file mode 100644 index 00000000..f5e437a8 --- /dev/null +++ b/src/types/formeo.d.ts @@ -0,0 +1,857 @@ +/** + * 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 + addChild(childData?: Record | ComponentId, index?: number): FormeoComponent + 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 + [key: string]: unknown +} + +export type ControlDefinition = FieldControlDefinition + +/** + * 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 + onAddChild?: ComponentEventHandler + onRemove?: ComponentEventHandler + onRender?: ComponentEventHandler + 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 +} + +/** `detail` of the added, removed and updated events; which keys are set depends on the event. */ +export interface FormeoChangeDetail { + entity?: FormeoComponent + componentId?: ComponentId + componentType?: ComponentType + parent?: FormeoComponent + dataPath?: string + changePath?: string + value?: unknown + previousValue?: unknown + changeType?: 'added' | 'removed' | 'changed' | 'unchanged' + 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 +} + +/** The + page tab (editor `pages` option). */ +export interface BeforeAddPageDetail { + componentType: 'stage' + index: number +} + +export type BeforeAddDetail = BeforeAddFieldDetail | BeforeAddLayoutDetail | 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]: HTMLElement | undefined +} + +/** A selector, an element, or a jQuery object. */ +export type ContainerOption = string | HTMLElement | 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 + } + interface DocumentEventMap extends FormeoDomEventMap {} +} diff --git a/src/types/formeo.test-d.ts b/src/types/formeo.test-d.ts new file mode 100644 index 00000000..4109994d --- /dev/null +++ b/src/types/formeo.test-d.ts @@ -0,0 +1,244 @@ +// 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 ControlDefinition, + type FormData, + 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 }, +} + +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}` + } +} + +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.addEventListener('formeoBeforeRemove', evt => { + if (evt.detail.componentType === 'row') { + evt.preventDefault() + } +}) +document.addEventListener('formeoEditOpened', evt => evt.detail.componentId) + +const rendererOptions: FormeoRendererOptions = { + renderContainer: '#formeo-renderer', + 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 + +export { + and, + badFormActions, + badHook, + badLogical, + badPagination, + badStage, + bare, + clearAll, + fromGlobal, + headless, + html, + jquery, + json, + newPage, + typo, + unnarrowed, + 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"] +} From 5d7d2f6dbbd94dfd371fddbbfaa896e22ea35588 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 16:39:54 +0100 Subject: [PATCH 02/10] ci: type-check the public definitions on pull requests and pre-push Refs #184 --- .github/workflows/pull-request.yml | 2 ++ lefthook.yml | 2 ++ 2 files changed, 4 insertions(+) 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/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 From fcdb782f3c3da7764c7fbcb33e69c06b8d131602 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 16:52:35 +0100 Subject: [PATCH 03/10] fix(types): type the legacy second call of component onRender and onAddChild Refs #184 --- src/types/formeo.d.ts | 6 ++++-- src/types/formeo.test-d.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/types/formeo.d.ts b/src/types/formeo.d.ts index f5e437a8..c3ea7245 100644 --- a/src/types/formeo.d.ts +++ b/src/types/formeo.d.ts @@ -299,9 +299,11 @@ export interface ComponentEventData { export interface ComponentEvents { onAdd?: ComponentEventHandler - onAddChild?: 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 - onRender?: 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 } diff --git a/src/types/formeo.test-d.ts b/src/types/formeo.test-d.ts index 4109994d..d7d22284 100644 --- a/src/types/formeo.test-d.ts +++ b/src/types/formeo.test-d.ts @@ -2,8 +2,11 @@ // 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 FormeoComponent, FormeoEditor, type FormeoEditorOptions, type FormeoFormData, @@ -61,6 +64,25 @@ const emailControl: ControlDefinition = { 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 + } else { + evt.parent.id + evt.child.id + } + }, +} + const describeAdd = (detail: BeforeAddDetail): string => { switch (detail.componentType) { case 'field': @@ -223,6 +245,14 @@ 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, @@ -232,6 +262,7 @@ export { badStage, bare, clearAll, + componentEvents, fromGlobal, headless, html, @@ -240,5 +271,7 @@ export { newPage, typo, unnarrowed, + unnarrowedAddChild, + unnarrowedRender, values, } From 7a70dfded462329fc1c3c1ddacd5aa4fa13ac99b Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 16:58:08 +0100 Subject: [PATCH 04/10] feat(types): type control set definitions and their onBeforeAdd detail A controls.elements entry with controlSet (no tag of its own) and componentType 'controlSet' with data { layout, row, fields } in the onBeforeAdd detail. Refs #227 --- src/types/control-sets.test-d.ts | 62 ++++++++++++++++++++++++++++++++ src/types/formeo.d.ts | 43 ++++++++++++++++++++-- src/types/formeo.test-d.ts | 2 ++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/types/control-sets.test-d.ts 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 index c3ea7245..312cae84 100644 --- a/src/types/formeo.d.ts +++ b/src/types/formeo.d.ts @@ -229,10 +229,33 @@ export interface FieldControlDefinition extends ControlDefinitionBase { children?: unknown /** e.g. `onRender`, run on the field's preview; functions are not saved in formData. */ action?: Record + controlSet?: never [key: string]: unknown } -export type ControlDefinition = FieldControlDefinition +/** 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, @@ -423,13 +446,29 @@ export interface BeforeAddLayoutDetail extends BeforeAddPlacement { 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 | BeforeAddPageDetail +export type BeforeAddDetail = + | BeforeAddFieldDetail + | BeforeAddLayoutDetail + | BeforeAddControlSetDetail + | BeforeAddPageDetail export interface BeforeRemoveComponentDetail { component: FormeoComponent diff --git a/src/types/formeo.test-d.ts b/src/types/formeo.test-d.ts index d7d22284..882f9a6b 100644 --- a/src/types/formeo.test-d.ts +++ b/src/types/formeo.test-d.ts @@ -92,6 +92,8 @@ const describeAdd = (detail: BeforeAddDetail): string => { return `${detail.addedVia} ${detail.parent.id} ${detail.index}` case 'stage': return `page ${detail.index}` + case 'controlSet': + return `${detail.data.layout} ${detail.data.fields.length}` } } From 18fd600ca8e0ff24891db2604310cb1c403665fd Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 17:06:19 +0100 Subject: [PATCH 05/10] build(types): publish the type definitions with the package Copy src/types/formeo.d.ts to dist/formeo.d.ts and dist/formeo.d.cts after build:lib and point types plus every exports condition at them. dist.test.mjs now reads nested exports conditions and checks that tsc resolves the types from bundler and nodenext (ESM and CJS) consumers. Refs #184 --- package.json | 19 +++++++++--- src/lib/js/package-types.test.mjs | 38 ++++++++++++++++++++++++ tools/build-types.mjs | 27 +++++++++++++++++ tools/dist.test.mjs | 48 +++++++++++++++++++++++++++++-- 4 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 src/lib/js/package-types.test.mjs create mode 100644 tools/build-types.mjs diff --git a/package.json b/package.json index ed583d25..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", @@ -100,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", 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/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) +} From 48e4031719dbfe0a8ebf3235322a78224aee2335 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 17:13:10 +0100 Subject: [PATCH 06/10] docs(events): say that edit panel events fire for stages too Refs #316 --- docs/options/events/README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/options/events/README.md b/docs/options/events/README.md index 41fc0288..0d20d404 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,8 +186,9 @@ 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 +`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'`. From 5a82883ab9074793fd33560c0dbe1a42b973b9ca Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 17:15:15 +0100 Subject: [PATCH 07/10] docs: document the shipped TypeScript definitions and fix action names Add docs/typescript.md, rewrite the README TypeScript section, and correct click.btn/save.form, the onSave example and the optional editor options argument. Refs #184 --- CLAUDE.md | 4 +- README.md | 26 ++++++--- docs/README.md | 4 ++ docs/editor/README.md | 2 +- docs/options/actions/README.md | 4 +- docs/typescript.md | 102 +++++++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 docs/typescript.md 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/typescript.md b/docs/typescript.md new file mode 100644 index 00000000..6bc9fdd3 --- /dev/null +++ b/docs/typescript.md @@ -0,0 +1,102 @@ +# 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. + +## 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. In a module file it clashes with the shipped +types (TS2300 duplicate identifier); in a global `.d.ts` it replaces them. From 80d128b8434462188f71e487bdf12220e954b107 Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 17:39:55 +0100 Subject: [PATCH 08/10] fix(types): accept any Element as a container, and type page reorders and a field's addChild Refs #184 --- src/types/formeo.d.ts | 33 +++++++++++++++++++++++++++------ src/types/formeo.test-d.ts | 21 ++++++++++++++++++++- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/types/formeo.d.ts b/src/types/formeo.d.ts index 312cae84..3fe3ed55 100644 --- a/src/types/formeo.d.ts +++ b/src/types/formeo.d.ts @@ -183,7 +183,8 @@ export interface FormeoComponent { set(path: string, value: unknown): unknown /** Removes the property at `path`, or the component itself when called without one. */ remove(path?: string): unknown - addChild(childData?: Record | ComponentId, index?: number): FormeoComponent + /** `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 } @@ -380,9 +381,20 @@ export interface FormeoEvent { 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 + entity?: FormeoComponent | ComponentStore componentId?: ComponentId componentType?: ComponentType parent?: FormeoComponent @@ -390,7 +402,8 @@ export interface FormeoChangeDetail { changePath?: string value?: unknown previousValue?: unknown - changeType?: 'added' | 'removed' | 'changed' | 'unchanged' + /** `'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 } @@ -698,11 +711,15 @@ export interface FormeoActions { /** 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]: HTMLElement | undefined + readonly [index: number]: Element | undefined } -/** A selector, an element, or a jQuery object. */ -export type ContainerOption = string | HTMLElement | JQueryLike | null +/** + * 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 and renderer then treat a container that resolves to nothing as "not attached" rather than throwing. + */ +export type ContainerOption = string | Element | JQueryLike | null export interface I18nOptions { /** Where language files are fetched from. */ @@ -894,5 +911,9 @@ declare global { 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 index 882f9a6b..c0e4db76 100644 --- a/src/types/formeo.test-d.ts +++ b/src/types/formeo.test-d.ts @@ -6,6 +6,7 @@ import { type ComponentEvents, type ControlDefinition, type FormData, + type FormeoChangeDetail, type FormeoComponent, FormeoEditor, type FormeoEditorOptions, @@ -76,6 +77,9 @@ const componentEvents: ComponentEvents = { 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 @@ -83,6 +87,13 @@ const componentEvents: ComponentEvents = { }, } +// 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': @@ -175,6 +186,9 @@ if (editor.initState === 'destroyed' || editor.isDestroyed) { } 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') { @@ -182,9 +196,12 @@ document.addEventListener('formeoBeforeRemove', evt => { } }) 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: '#formeo-renderer', + renderContainer: queriedRenderContainer, formData, pagination: { type: 'wizard', heading: 3, submit: true, labels: { next: 'Weiter', status: '{title} ({n}/{count})' } }, elements: { email: { action: { onRender: elem => elem.focus() } } }, @@ -265,12 +282,14 @@ export { bare, clearAll, componentEvents, + describeReorder, fromGlobal, headless, html, jquery, json, newPage, + queried, typo, unnarrowed, unnarrowedAddChild, From 04526469876e0bfb6748d0ad18ca4df71515630e Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 17:46:04 +0100 Subject: [PATCH 09/10] docs(types): correct the shim-migration notes and state the supported TypeScript versions Refs #184 --- docs/options/events/README.md | 7 ++++--- docs/typescript.md | 26 ++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/options/events/README.md b/docs/options/events/README.md index 0d20d404..6fcd25a0 100644 --- a/docs/options/events/README.md +++ b/docs/options/events/README.md @@ -188,9 +188,10 @@ don't run them. `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. +(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 index 6bc9fdd3..0ee0ab40 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -7,6 +7,11 @@ modules or CommonJS). There is nothing extra to install. Projects still on `modu 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` @@ -98,5 +103,22 @@ program, for example with `/// ` in one file. ## Replacing your own declarations -If your project has its own `declare module 'formeo'` block, delete it. In a module file it clashes with the shipped -types (TS2300 duplicate identifier); in a global `.d.ts` it replaces them. +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`. + +The same silent-merge-under-`skipLibCheck: true` behavior applies to a `declare global { interface Window { +FormeoEditor: any } }` some projects added before formeo shipped its own `Window` typing: with `skipLibCheck: true` +it merges without error, but your `any` wins there, so `window.FormeoEditor` loses the shipped typing wherever it's +read; with `skipLibCheck: false` it's TS2717 ("subsequent property declarations must have the same type"). Either +way, remove it now that formeo types `Window.FormeoEditor` itself. + +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. From 1c6675919460178c9e8b8d909608a2e4b4bacf4a Mon Sep 17 00:00:00 2001 From: Kevin Chappell Date: Mon, 28 Sep 2026 19:54:54 +0100 Subject: [PATCH 10/10] docs(types): say what a leftover Window shim does, and when render() throws Refs #184 --- docs/typescript.md | 8 +++----- src/types/formeo.d.ts | 3 ++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/typescript.md b/docs/typescript.md index 0ee0ab40..6b45fe55 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -113,11 +113,9 @@ it lives and on `skipLibCheck`: - 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`. -The same silent-merge-under-`skipLibCheck: true` behavior applies to a `declare global { interface Window { -FormeoEditor: any } }` some projects added before formeo shipped its own `Window` typing: with `skipLibCheck: true` -it merges without error, but your `any` wins there, so `window.FormeoEditor` loses the shipped typing wherever it's -read; with `skipLibCheck: false` it's TS2717 ("subsequent property declarations must have the same type"). Either -way, remove it now that formeo types `Window.FormeoEditor` itself. +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 diff --git a/src/types/formeo.d.ts b/src/types/formeo.d.ts index 3fe3ed55..4771c501 100644 --- a/src/types/formeo.d.ts +++ b/src/types/formeo.d.ts @@ -717,7 +717,8 @@ export interface JQueryLike { /** * 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 and renderer then treat a container that resolves to nothing as "not attached" rather than throwing. + * 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