diff --git a/.gitignore b/.gitignore index 53ae236..6fb7651 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ package-lock.json yarn.lock # generated typing output -types +dist *.tsbuildinfo # copied documentation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bfd17d3..545ae0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,12 +17,20 @@ The module is released automatically from the `main` and `next` branches using [ This repository uses `pnpm` as its package manager. See the `pnpm` [installation guide](https://pnpm.io/installation) to set it up through whatever method you prefer. -After cloning the repository, use the `setup` script to install dependencies and run all checks: +After cloning the repository, use the `setup` script to install dependencies, build, and run all checks: ```shell pnpm run setup ``` +### Build + +To build types and docs: + +```shell +pnpm run build +``` + ### Lint and format Run auto-formatting to ensure any changes adhere to the code style of the repository: @@ -64,13 +72,7 @@ pnpm run install:3 pnpm run all:legacy ``` -### Docs - -Use the `docs` script to ensure the README's table of contents is up to date: - -```shell -pnpm run docs -``` +### Contributors Use `contributors:add` to add a contributor to the README: diff --git a/eslint.config.js b/eslint.config.js index 857a556..c4308ba 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -37,7 +37,7 @@ export default tseslint.config( }, { name: 'ignores', - ignores: ['**/coverage/**', '**/types/**'], + ignores: ['**/coverage/**', '**/dist/**'], }, { name: 'simple-import-sort', @@ -68,6 +68,7 @@ export default tseslint.config( name: 'extras', rules: { 'unicorn/prevent-abbreviations': 'off', + 'unicorn/prefer-dom-node-append': 'off', }, }, { diff --git a/package.json b/package.json index 2c11cbd..020da30 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "scripts": { "all": "pnpm contributors:generate && pnpm build && pnpm format && pnpm test:all && pnpm typecheck", "all:legacy": "pnpm build:types && pnpm test:all:legacy && pnpm typecheck:legacy", - "docs": "", "lint": "prettier . --check && eslint .", "format": "prettier . --write && eslint . --fix", "setup": "pnpm install:5 && pnpm all", @@ -21,7 +20,7 @@ "test:all": "pnpm test:vitest:jsdom && pnpm test:vitest:happy-dom && pnpm test:jest && pnpm test:examples", "test:all:legacy": "pnpm test:vitest:jsdom && pnpm test:vitest:happy-dom && pnpm test:jest", "build": "pnpm build:types && pnpm build:docs", - "build:types": "tsc --build && cp packages/svelte/src/component-types.d.ts packages/svelte/types", + "build:types": "tsc --build && cp packages/svelte/src/core/types.d.ts packages/svelte/dist/core", "build:docs": "remark --output --use remark-toc --use remark-code-import --use unified-prettier README.md examples && cp -f README.md packages/svelte", "contributors:add": "all-contributors add", "contributors:generate": "all-contributors generate", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index c28a692..11837b0 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -5,24 +5,24 @@ "main": "src/index.js", "exports": { ".": { - "types": "./types/index.d.ts", + "types": "./dist/index.d.ts", "default": "./src/index.js" }, "./svelte5": { - "types": "./types/index.d.ts", + "types": "./dist/index.d.ts", "default": "./src/index.js" }, "./vitest": { - "types": "./types/vitest.d.ts", + "types": "./dist/vitest.d.ts", "default": "./src/vitest.js" }, "./vite": { - "types": "./types/vite.d.ts", + "types": "./dist/vite.d.ts", "default": "./src/vite.js" } }, "type": "module", - "types": "types/index.d.ts", + "types": "dist/index.d.ts", "license": "MIT", "homepage": "https://github.com/testing-library/svelte-testing-library#readme", "repository": { diff --git a/packages/svelte/src/core/index.js b/packages/svelte/src/core/index.js index ffdd5ac..eb6d2b2 100644 --- a/packages/svelte/src/core/index.js +++ b/packages/svelte/src/core/index.js @@ -5,9 +5,6 @@ * Will switch to legacy, class-based mounting logic * if it looks like we're in a Svelte <= 4 environment. */ -export { addCleanupTask, cleanup } from './cleanup.js' -export { mount } from './mount.js' -export { - UnknownSvelteOptionsError, - validateOptions, -} from './validate-options.js' +export * from './cleanup.js' +export * from './mount.js' +export * from './setup.js' diff --git a/packages/svelte/src/core/mount.js b/packages/svelte/src/core/mount.js index 56951ae..6e5d1c5 100644 --- a/packages/svelte/src/core/mount.js +++ b/packages/svelte/src/core/mount.js @@ -5,14 +5,7 @@ import * as Svelte from 'svelte' import { addCleanupTask, removeCleanupTask } from './cleanup.js' import { createProps } from './props.svelte.js' - -/** Whether we're using Svelte >= 5. */ -const IS_MODERN_SVELTE = typeof Svelte.mount === 'function' - -/** Allowed options to the `mount` call or legacy component constructor. */ -const ALLOWED_MOUNT_OPTIONS = IS_MODERN_SVELTE - ? ['target', 'anchor', 'props', 'events', 'context', 'intro'] - : ['target', 'accessors', 'anchor', 'props', 'hydrate', 'intro', 'context'] +import { IS_MODERN_SVELTE } from './svelte-version.js' /** Mount a modern Svelte 5 component into the DOM. */ const mountModern = (Component, options) => { @@ -78,7 +71,7 @@ const mountComponent = IS_MODERN_SVELTE ? mountModern : mountLegacy * rerender: (props: Partial>) => Promise * }} */ -const mount = (Component, options = {}) => { +const mount = (Component, options) => { const { component, unmount, rerender } = mountComponent(Component, options) return { @@ -92,4 +85,4 @@ const mount = (Component, options = {}) => { } } -export { ALLOWED_MOUNT_OPTIONS, mount } +export { mount } diff --git a/packages/svelte/src/core/setup.js b/packages/svelte/src/core/setup.js new file mode 100644 index 0000000..25466be --- /dev/null +++ b/packages/svelte/src/core/setup.js @@ -0,0 +1,86 @@ +/** Set up the document to render a component. */ +import { addCleanupTask } from './cleanup.js' +import { IS_MODERN_SVELTE } from './svelte-version.js' + +/** Allowed options to the `mount` call or legacy component constructor. */ +const ALLOWED_MOUNT_OPTIONS = IS_MODERN_SVELTE + ? ['target', 'anchor', 'props', 'events', 'context', 'intro'] + : ['target', 'accessors', 'anchor', 'props', 'hydrate', 'intro', 'context'] + +class UnknownSvelteOptionsError extends TypeError { + constructor(unknownOptions) { + super(`Unknown options. + + Unknown: [ ${unknownOptions.join(', ')} ] + Allowed: [ ${ALLOWED_MOUNT_OPTIONS.join(', ')} ] + + To pass both Svelte options and props to a component, + or to use props that share a name with a Svelte option, + you must place all your props under the \`props\` key: + + render(Component, { props: { /** props here **/ } }) +`) + this.name = 'UnknownSvelteOptionsError' + } +} + +/** + * Validate a component's mount options. + * + * @template {import('./types.js').Component} C + * @param {import('./types.js').ComponentOptions} options - props or mount options + * @returns {Partial>} + */ +const validateOptions = (options) => { + const isProps = !Object.keys(options).some((option) => + ALLOWED_MOUNT_OPTIONS.includes(option) + ) + + if (isProps) { + return { props: options } + } + + // Check if any props and Svelte options were accidentally mixed. + const unknownOptions = Object.keys(options).filter( + (option) => !ALLOWED_MOUNT_OPTIONS.includes(option) + ) + + if (unknownOptions.length > 0) { + throw new UnknownSvelteOptionsError(unknownOptions) + } + + return options +} + +/** + * Set up the document to render a component. + * + * @template {import('./types.js').Component} C + * @param {import('./types.js').ComponentOptions} componentOptions - props or mount options + * @param {{ baseElement?: HTMLElement | undefined }} setupOptions - base element of the document to bind any queries + * @returns {{ + * baseElement: HTMLElement, + * target: HTMLElement, + * mountOptions: import('./types.js).MountOptions + * }} + */ +const setup = (componentOptions, setupOptions) => { + const mountOptions = validateOptions(componentOptions) + + const baseElement = + setupOptions.baseElement ?? mountOptions.target ?? document.body + + const target = + mountOptions.target ?? + baseElement.appendChild(document.createElement('div')) + + addCleanupTask(() => { + if (target.parentNode === document.body) { + target.remove() + } + }) + + return { baseElement, target, mountOptions: { ...mountOptions, target } } +} + +export { setup, UnknownSvelteOptionsError } diff --git a/packages/svelte/src/core/svelte-version.js b/packages/svelte/src/core/svelte-version.js new file mode 100644 index 0000000..72f761a --- /dev/null +++ b/packages/svelte/src/core/svelte-version.js @@ -0,0 +1,7 @@ +/** Detect which version of Svelte we're using */ +import * as Svelte from 'svelte' + +/** Whether we're using Svelte >= 5. */ +const IS_MODERN_SVELTE = typeof Svelte.mount === 'function' + +export { IS_MODERN_SVELTE } diff --git a/packages/svelte/src/component-types.d.ts b/packages/svelte/src/core/types.d.ts similarity index 88% rename from packages/svelte/src/component-types.d.ts rename to packages/svelte/src/core/types.d.ts index 1281763..7edd519 100644 --- a/packages/svelte/src/component-types.d.ts +++ b/packages/svelte/src/core/types.d.ts @@ -1,4 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-deprecated */ +/** + * Component and utility types. + * + * Supports components from Svelte 3, 4, and 5. + */ import type { Component as ModernComponent, ComponentConstructorOptions as LegacyConstructorOptions, @@ -59,3 +64,8 @@ export type Exports = IS_MODERN_SVELTE extends true export type MountOptions = IS_MODERN_SVELTE extends true ? Parameters, Exports>>[1] : LegacyConstructorOptions> + +/** A component's props or some of its mount options. */ +export type ComponentOptions = + | Props + | Partial> diff --git a/packages/svelte/src/core/validate-options.js b/packages/svelte/src/core/validate-options.js deleted file mode 100644 index 294a317..0000000 --- a/packages/svelte/src/core/validate-options.js +++ /dev/null @@ -1,41 +0,0 @@ -import { ALLOWED_MOUNT_OPTIONS } from './mount.js' - -class UnknownSvelteOptionsError extends TypeError { - constructor(unknownOptions, allowedOptions) { - super(`Unknown options. - - Unknown: [ ${unknownOptions.join(', ')} ] - Allowed: [ ${allowedOptions.join(', ')} ] - - To pass both Svelte options and props to a component, - or to use props that share a name with a Svelte option, - you must place all your props under the \`props\` key: - - render(Component, { props: { /** props here **/ } }) -`) - this.name = 'UnknownSvelteOptionsError' - } -} - -const validateOptions = (options) => { - const isProps = !Object.keys(options).some((option) => - ALLOWED_MOUNT_OPTIONS.includes(option) - ) - - if (isProps) { - return { props: options } - } - - // Check if any props and Svelte options were accidentally mixed. - const unknownOptions = Object.keys(options).filter( - (option) => !ALLOWED_MOUNT_OPTIONS.includes(option) - ) - - if (unknownOptions.length > 0) { - throw new UnknownSvelteOptionsError(unknownOptions, ALLOWED_MOUNT_OPTIONS) - } - - return options -} - -export { UnknownSvelteOptionsError, validateOptions } diff --git a/packages/svelte/src/index.js b/packages/svelte/src/index.js index 2c88a28..dd05567 100644 --- a/packages/svelte/src/index.js +++ b/packages/svelte/src/index.js @@ -23,7 +23,11 @@ if (typeof process !== 'undefined' && !process.env.STL_SKIP_AUTO_CLEANUP) { export * from '@testing-library/dom' // export svelte-specific functions and custom `fireEvent` -export { UnknownSvelteOptionsError } from './core/index.js' -export * from './pure.js' -// `fireEvent` must be named to take priority over wildcard from @testing-library/dom -export { fireEvent } from './pure.js' +export { + act, + cleanup, + fireEvent, + render, + setup, + UnknownSvelteOptionsError, +} from './pure.js' diff --git a/packages/svelte/src/pure.js b/packages/svelte/src/pure.js index fbb73c2..8ae70f4 100644 --- a/packages/svelte/src/pure.js +++ b/packages/svelte/src/pure.js @@ -7,13 +7,13 @@ import { } from '@testing-library/dom' import * as Svelte from 'svelte' -import { addCleanupTask, mount, validateOptions } from './core/index.js' +import * as Core from './core/index.js' /** * Customize how Svelte renders the component. * - * @template {import('./component-types.js').Component} C - * @typedef {import('./component-types.js').Props | Partial>} SvelteComponentOptions + * @template {import('./core/types.js').Component} C + * @typedef {import('./core/types.js').ComponentOptions} SvelteComponentOptions */ /** @@ -29,15 +29,15 @@ import { addCleanupTask, mount, validateOptions } from './core/index.js' /** * The rendered component and bound testing functions. * - * @template {import('./component-types.js').Component} C + * @template {import('./core/types.js').Component} C * @template {import('@testing-library/dom').Queries} [Q=typeof import('@testing-library/dom').queries] * * @typedef {{ * container: HTMLElement * baseElement: HTMLElement - * component: import('./component-types.js').Exports + * component: import('./core/types.js').Exports * debug: (el?: HTMLElement | DocumentFragment) => void - * rerender: (props: Partial>) => Promise + * rerender: (props: Partial>) => Promise * unmount: () => void * } & { * [P in keyof Q]: import('@testing-library/dom').BoundFunction @@ -47,35 +47,25 @@ import { addCleanupTask, mount, validateOptions } from './core/index.js' /** * Render a component into the document. * - * @template {import('./component-types.js').Component} C + * @template {import('./core/types.js').Component} C * @template {import('@testing-library/dom').Queries} [Q=typeof import('@testing-library/dom').queries] * - * @param {import('./component-types.js').ComponentType} Component - The component to render. + * @param {import('./core/types.js').ComponentType} Component - The component to render. * @param {SvelteComponentOptions} options - Customize how Svelte renders the component. * @param {RenderOptions} renderOptions - Customize how Testing Library sets up the document and binds queries. * @returns {RenderResult} The rendered component and bound testing functions. */ const render = (Component, options = {}, renderOptions = {}) => { - options = validateOptions(options) - - const baseElement = - renderOptions.baseElement ?? options.target ?? document.body + const { baseElement, target, mountOptions } = Core.setup( + options, + renderOptions + ) const queries = getQueriesForElement(baseElement, renderOptions.queries) - const target = - // eslint-disable-next-line unicorn/prefer-dom-node-append - options.target ?? baseElement.appendChild(document.createElement('div')) - - addCleanupTask(() => { - if (target.parentNode === document.body) { - target.remove() - } - }) - - const { component, unmount, rerender } = mount( + const { component, unmount, rerender } = Core.mount( Component.default ?? Component, - { ...options, target } + mountOptions ) return { @@ -115,11 +105,16 @@ const setup = () => { eventWrapper: Svelte.flushSync ?? ((cb) => cb()), }) - addCleanupTask(() => { + Core.addCleanupTask(() => { configureDTL(originalDTLConfig) }) } +/** Unmount components, remove elements added to ``, and reset `@testing-library/dom`. */ +const cleanup = () => { + Core.cleanup() +} + /** * Call a function and wait for Svelte to flush pending changes. * @@ -160,5 +155,5 @@ for (const [key, baseEvent] of Object.entries(baseFireEvent)) { fireEvent[key] = async (...args) => act(() => baseEvent(...args)) } -export { cleanup } from './core/index.js' -export { act, fireEvent, render, setup } +export { UnknownSvelteOptionsError } from './core/index.js' +export { act, cleanup, fireEvent, render, setup } diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json index ca9fee3..66fe363 100644 --- a/packages/svelte/tsconfig.json +++ b/packages/svelte/tsconfig.json @@ -4,13 +4,12 @@ "allowJs": true, "skipLibCheck": true, "strict": true, - "types": ["svelte"], "composite": true, "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, "rootDir": "src", - "outDir": "types" + "outDir": "dist" }, "include": ["src"] }