diff --git a/packages/demo/vite.config.mts b/packages/demo/vite.config.mts index 34a715b8..e8cfd1b5 100644 --- a/packages/demo/vite.config.mts +++ b/packages/demo/vite.config.mts @@ -11,11 +11,7 @@ export default defineConfig({ }, server: { port: 4000, - cors: true, open: true, host: 'localhost', }, - optimizeDeps: { - exclude: ['multiple-select-vanilla'], - }, }); diff --git a/packages/multiple-select-vanilla/build-prod.mjs b/packages/multiple-select-vanilla/build-prod.mjs index b6fc6a91..04fd84c1 100644 --- a/packages/multiple-select-vanilla/build-prod.mjs +++ b/packages/multiple-select-vanilla/build-prod.mjs @@ -39,8 +39,9 @@ function runBuild(options) { entryPoints: ['./src/index.ts'], bundle: true, minify: true, - target: 'es2021', + target: 'es2022', sourcemap: true, + sourcesContent: false, logLevel: 'error', }, ...options, diff --git a/packages/multiple-select-vanilla/build-watch.mjs b/packages/multiple-select-vanilla/build-watch.mjs index 9dddad63..52025172 100644 --- a/packages/multiple-select-vanilla/build-watch.mjs +++ b/packages/multiple-select-vanilla/build-watch.mjs @@ -21,7 +21,7 @@ function runBuild(options) { bundle: true, minify: env === 'production', format: 'esm', - target: 'es2021', + target: 'es2022', sourcemap: true, logLevel: 'error', outfile: 'dist/index.js', diff --git a/packages/multiple-select-vanilla/package.json b/packages/multiple-select-vanilla/package.json index c43bdeac..ad26c138 100644 --- a/packages/multiple-select-vanilla/package.json +++ b/packages/multiple-select-vanilla/package.json @@ -21,10 +21,19 @@ "homepage": "https://github.com/ghiscoding/multiple-select-vanilla", "license": "MIT", "type": "module", + "files": [ + "dist", + "src" + ], + "sideEffects": [ + "**/*.css", + "**/*.scss" + ], "main": "./dist/index.js", "exports": { ".": { "types": "./dist/index.d.ts", + "import": "./dist/index.js", "default": "./dist/index.js" }, "./dist/locales/*": "./dist/locales/*", @@ -54,7 +63,7 @@ "dev:init": "pnpm sass:build && pnpm sass:copy && pnpm build:all && pnpm build:types:prod", "build:all": "node build-prod.mjs", "build:watch": "cross-env NODE_ENV=development node build-watch.mjs", - "build:esm": "esbuild src/index.ts --bundle --minify --format=esm --target=es2021 --sourcemap --outfile=dist/index.js", + "build:esm": "esbuild src/index.ts --bundle --minify --format=esm --target=es2022 --sourcemap --sources-content=false --outfile=dist/index.js", "build:types": "tsc --emitDeclarationOnly --incremental --declarationMap false --outDir dist", "build:types:prod": "tsc --emitDeclarationOnly --incremental --declarationMap --outDir dist", "sass:build": "sass src/styles:dist/styles/css --style=compressed --quiet-deps --no-source-map && pnpm sass:build:closing", diff --git a/packages/multiple-select-vanilla/src/MultipleSelectInstance.ts b/packages/multiple-select-vanilla/src/MultipleSelectInstance.ts index 849e9a70..ecee6bd3 100644 --- a/packages/multiple-select-vanilla/src/MultipleSelectInstance.ts +++ b/packages/multiple-select-vanilla/src/MultipleSelectInstance.ts @@ -1,7 +1,7 @@ /** * @author zhixin wen */ -import Constants from './constants.js'; +import { BLOCK_ROWS, CLUSTER_BLOCKS, getDefaultOptions } from './constants.js'; import type { CollectionData, HtmlStruct, OptGroupRowData, OptionDataObject, OptionRowData } from './models/interfaces.js'; import type { MultipleSelectLocale, MultipleSelectLocales } from './models/locale.interface.js'; import type { ClickedGroup, ClickedOption, CloseReason, MultipleSelectOption } from './models/multipleSelectOption.interface.js'; @@ -79,7 +79,7 @@ export class MultipleSelectInstance { protected elm: HTMLInputElement | HTMLSelectElement | HTMLSpanElement, options?: Partial>, ) { - this.options = { ...Constants.DEFAULTS, ...this.elm.dataset, ...options } as MultipleSelectOption; + this.options = { ...getDefaultOptions(), ...this.elm.dataset, ...options } as MultipleSelectOption; this._bindEventService = new BindingEventService({ distinctEvent: true }); } @@ -493,7 +493,7 @@ export class MultipleSelectInstance { offset = -1; } - if (this.options.virtualScroll && rows.length > Constants.BLOCK_ROWS * Constants.CLUSTER_BLOCKS) { + if (this.options.virtualScroll && rows.length > BLOCK_ROWS * CLUSTER_BLOCKS) { const dropVisible = this.dropElm && this.dropElm?.style.display !== 'none'; if (!dropVisible && this.dropElm) { this.dropElm.style.left = '-10000'; @@ -1987,8 +1987,7 @@ export class MultipleSelectInstance { if (this.dropElm && this.parentElm) { const { bottom: spaceBottom, top: spaceTop } = calculateAvailableSpace(this.dropElm); const { top: selectOffsetTop, left: selectOffsetLeft } = getOffset(this.parentElm) as HtmlElementPosition; - const msDropHeight = this.dropElm.getBoundingClientRect().height; - const msDropWidth = this.dropElm.getBoundingClientRect().width; + const { height: msDropHeight, width: msDropWidth } = this.dropElm.getBoundingClientRect(); const windowWidth = document.body.offsetWidth || window.innerWidth; const selectParentWidth = this.parentElm.getBoundingClientRect().width; @@ -2006,7 +2005,7 @@ export class MultipleSelectInstance { if (newOffsetTop > 0 || forceToggle) { position = 'top'; - this.dropElm.style.top = `${newOffsetTop < 0 ? 0 : newOffsetTop}px`; + this.dropElm.style.top = `${newOffsetTop}px`; } } else { // without container, we simply need to add the "top" class to the drop diff --git a/packages/multiple-select-vanilla/src/constants.ts b/packages/multiple-select-vanilla/src/constants.ts index bd1c8e2d..6d5b3b7b 100644 --- a/packages/multiple-select-vanilla/src/constants.ts +++ b/packages/multiple-select-vanilla/src/constants.ts @@ -2,8 +2,8 @@ import { English } from './locales/en-US.js'; import type { LabelFilter, TextFilter } from './models/interfaces.js'; import type { MultipleSelectOption } from './models/multipleSelectOption.interface.js'; -const BLOCK_ROWS = 50; -const CLUSTER_BLOCKS = 4; +export const BLOCK_ROWS = 50; +export const CLUSTER_BLOCKS = 4; const noopFalse = () => false; const noopTrue = () => true; @@ -86,7 +86,7 @@ const DEFAULTS: Partial = { onAfterDestroy: noopFalse, onDestroyed: noopFalse, sanitizer: text => { - if ('setHTML' in Element.prototype) { + if (typeof Element !== 'undefined' && 'setHTML' in Element.prototype && typeof Sanitizer === 'function') { const container = document.createElement('div'); // @ts-expect-error: experimental API container.setHTML(text, { @@ -100,38 +100,12 @@ const DEFAULTS: Partial = { }); return container.innerHTML; } - return text; - }, -}; - -const METHODS = [ - 'init', - 'getOptions', - 'refreshOptions', - 'getSelects', - 'setSelects', - 'enable', - 'disable', - 'open', - 'close', - 'check', - 'uncheck', - 'checkAll', - 'uncheckAll', - 'checkInvert', - 'focus', - 'blur', - 'refresh', - 'destroy', -]; -Object.assign(DEFAULTS, English); // load English as default locale - -const Constants = { - BLOCK_ROWS, - CLUSTER_BLOCKS, - DEFAULTS, - METHODS, + // Fail closed when the Sanitizer API is unavailable. The escaped string can + // safely be assigned to innerHTML while still displaying the original text. + return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); + }, }; -export default Constants; +/** Return fresh defaults so importing unrelated utilities has no module side effects. */ +export const getDefaultOptions = (): Partial => ({ ...DEFAULTS, ...English }); diff --git a/packages/multiple-select-vanilla/src/services/virtual-scroll.ts b/packages/multiple-select-vanilla/src/services/virtual-scroll.ts index 383a097f..424971bc 100644 --- a/packages/multiple-select-vanilla/src/services/virtual-scroll.ts +++ b/packages/multiple-select-vanilla/src/services/virtual-scroll.ts @@ -1,4 +1,4 @@ -import Constants from '../constants.js'; +import { BLOCK_ROWS, CLUSTER_BLOCKS } from '../constants.js'; import type { HtmlStruct, VirtualCache, VirtualScrollOption } from '../models/interfaces.js'; import { convertItemRowToHtml, createDomElement, emptyElement } from '../utils/domUtils.js'; @@ -103,9 +103,9 @@ export class VirtualScroll { this.parentEl.style.display = prevParentDisplay; } } - this.blockHeight = this.itemHeight * Constants.BLOCK_ROWS; - this.clusterRows = Constants.BLOCK_ROWS * Constants.CLUSTER_BLOCKS; - this.clusterHeight = this.blockHeight * Constants.CLUSTER_BLOCKS; + this.blockHeight = this.itemHeight * BLOCK_ROWS; + this.clusterRows = BLOCK_ROWS * CLUSTER_BLOCKS; + this.clusterHeight = this.blockHeight * CLUSTER_BLOCKS; } protected getNum() { @@ -115,7 +115,7 @@ export class VirtualScroll { } protected initData(rows: HtmlStruct[], num: number) { - if (rows.length < Constants.BLOCK_ROWS) { + if (rows.length < BLOCK_ROWS) { return { topOffset: 0, bottomOffset: 0, @@ -123,7 +123,7 @@ export class VirtualScroll { rows, }; } - const start = Math.max((this.clusterRows! - Constants.BLOCK_ROWS) * num, 0); + const start = Math.max((this.clusterRows! - BLOCK_ROWS) * num, 0); const end = start + this.clusterRows!; const topOffset = Math.max(start * this.itemHeight!, 0); const bottomOffset = Math.max((rows.length - end) * this.itemHeight!, 0); diff --git a/packages/multiple-select-vanilla/src/utils/domUtils.ts b/packages/multiple-select-vanilla/src/utils/domUtils.ts index 47229d1b..2c87ab08 100644 --- a/packages/multiple-select-vanilla/src/utils/domUtils.ts +++ b/packages/multiple-select-vanilla/src/utils/domUtils.ts @@ -83,17 +83,13 @@ export function createDomElement { globalThis.document = originalDocument; + if (originalElement === undefined) { + delete globalThis.Element; + } else { + globalThis.Element = originalElement; + } + if (originalSanitizer === undefined) { + delete globalThis.Sanitizer; + } else { + globalThis.Sanitizer = originalSanitizer; + } }); test('createDomElement rejects prototype and inherited built-in property names', () => { @@ -103,6 +115,103 @@ test('VirtualScroll uses a prototype-free cache across resets', () => { assert.equal(Object.getPrototypeOf(virtualScroll.cache), null); }); +test('VirtualScroll renders the expected rows when moving between clusters', () => { + let scrollListener; + let callbackCount = 0; + + const createElement = tagName => ({ + tagName, + className: '', + dataset: {}, + style: {}, + offsetHeight: 10, + appendChild: () => {}, + setAttribute: () => {}, + }); + globalThis.document = { createElement }; + + const children = []; + const listElement = { + children, + parentElement: { style: { display: 'block' } }, + scrollTop: 0, + appendChild(child) { + const currentIndex = children.indexOf(child); + if (currentIndex >= 0) { + children.splice(currentIndex, 1); + } + children.push(child); + }, + removeChild(child) { + children.splice(children.indexOf(child), 1); + }, + addEventListener: (_eventName, listener) => { + scrollListener = listener; + }, + removeEventListener: () => {}, + get firstChild() { + return children[0]; + }, + get lastChild() { + return children.at(-1); + }, + }; + const rows = Array.from({ length: 251 }, (_, index) => ({ + tagName: 'li', + props: { className: `row-${index}`, dataset: { key: `row-${index}` } }, + })); + + const virtualScroll = new VirtualScroll({ + rows, + scrollEl: listElement, + contentEl: listElement, + callback: () => callbackCount++, + }); + + assert.equal(virtualScroll.dataStart, 0); + assert.equal(virtualScroll.dataEnd, 200); + assert.equal(children[0].className, 'row-0'); + assert.equal(children.at(-1).className, 'virtual-scroll-bottom'); + + listElement.scrollTop = 1500; + scrollListener(); + + assert.equal(callbackCount, 1); + assert.equal(virtualScroll.dataStart, 150); + assert.equal(virtualScroll.dataEnd, 350); + assert.equal(children[0].className, 'virtual-scroll-top'); + assert.equal(children[1].className, 'row-150'); + assert.equal(children.at(-1).className, 'row-250'); +}); + +test('getSize preserves mode-specific DOM measurements before using the bounding rectangle fallback', () => { + const element = { + style: { width: '' }, + offsetWidth: 120, + scrollWidth: 240, + clientWidth: 100, + getBoundingClientRect: () => ({ width: 300 }), + }; + + assert.equal(getSize(element, 'outer', 'width'), 120); + assert.equal(getSize(element, 'scroll', 'width'), 240); + assert.equal(getSize(element, 'inner', 'width'), 100); + + element.offsetWidth = 0; + assert.equal(getSize(element, 'outer', 'width'), 300); +}); + +test('default sanitizer escapes HTML when the Sanitizer API is unavailable', () => { + globalThis.Element = class {}; + delete globalThis.Sanitizer; + + const instance = new MultipleSelectInstance({ dataset: {} }); + const sanitizer = instance.getOptions(false).sanitizer; + const maliciousHtml = `&`; + + assert.equal(sanitizer(maliciousHtml), '<img src=x onerror="alert('xss')">&'); +}); + test('options treat special names as own data without changing their prototype', () => { const maliciousOptions = JSON.parse('{"__proto__":{"polluted":true},"constructor":{"polluted":true},"toString":{"polluted":true}}'); const instance = new MultipleSelectInstance({ dataset: {} }, maliciousOptions); diff --git a/packages/multiple-select-vanilla/tsconfig.json b/packages/multiple-select-vanilla/tsconfig.json index 47f803ca..da75c38d 100644 --- a/packages/multiple-select-vanilla/tsconfig.json +++ b/packages/multiple-select-vanilla/tsconfig.json @@ -2,10 +2,10 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "target": "ES2021", + "target": "ES2022", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["es2021", "DOM"], + "lib": ["ES2022", "DOM"], "moduleResolution": "Bundler", "allowSyntheticDefaultImports": true, "experimentalDecorators": true,