Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions packages/demo/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,7 @@ export default defineConfig({
},
server: {
port: 4000,
cors: true,
open: true,
host: 'localhost',
},
optimizeDeps: {
exclude: ['multiple-select-vanilla'],
},
});
3 changes: 2 additions & 1 deletion packages/multiple-select-vanilla/build-prod.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/multiple-select-vanilla/build-watch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 10 additions & 1 deletion packages/multiple-select-vanilla/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*",
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 5 additions & 6 deletions packages/multiple-select-vanilla/src/MultipleSelectInstance.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @author zhixin wen <wenzhixin2010@gmail.com>
*/
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';
Expand Down Expand Up @@ -79,7 +79,7 @@ export class MultipleSelectInstance {
protected elm: HTMLInputElement | HTMLSelectElement | HTMLSpanElement,
options?: Partial<Omit<MultipleSelectOption, 'onHardDestroy' | 'onAfterHardDestroy'>>,
) {
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 });
}

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand Down
44 changes: 9 additions & 35 deletions packages/multiple-select-vanilla/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -86,7 +86,7 @@ const DEFAULTS: Partial<MultipleSelectOption> = {
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, {
Expand All @@ -100,38 +100,12 @@ const DEFAULTS: Partial<MultipleSelectOption> = {
});
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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
},
};

export default Constants;
/** Return fresh defaults so importing unrelated utilities has no module side effects. */
export const getDefaultOptions = (): Partial<MultipleSelectOption> => ({ ...DEFAULTS, ...English });
12 changes: 6 additions & 6 deletions packages/multiple-select-vanilla/src/services/virtual-scroll.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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() {
Expand All @@ -115,15 +115,15 @@ 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,
rowsAbove: 0,
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);
Expand Down
15 changes: 6 additions & 9 deletions packages/multiple-select-vanilla/src/utils/domUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,13 @@ export function createDomElement<T extends keyof HTMLElementTagNameMap, K extend
* @param item
* @param appendToElm
*/
export function createDomStructure(item: HtmlStruct, appendToElm?: HTMLElement, parentElm?: HTMLElement): HTMLElement {
export function createDomStructure(item: HtmlStruct, appendToElm?: HTMLElement, _parentElm?: HTMLElement): HTMLElement {
// to be CSP safe, we'll omit `innerHTML` and assign it manually afterward
const itemPropsOmitHtml = item.props?.innerHTML ? omitProp(item.props, 'innerHTML') : item.props;

const elm = createDomElement(item.tagName, objectRemoveEmptyProps(itemPropsOmitHtml, ['className', 'title', 'style']), appendToElm);
let parent: HTMLElement | null | undefined = parentElm;
if (!parent) {
parent = elm;
}

if (item.props.innerHTML) {
if (item.props?.innerHTML) {
elm.innerHTML = item.props.innerHTML; // at this point, string type should already be as TrustedHTML
}

Expand All @@ -107,11 +103,10 @@ export function createDomStructure(item: HtmlStruct, appendToElm?: HTMLElement,
// use recursion when finding item children
if (item.children) {
for (const childItem of item.children) {
createDomStructure(childItem, elm, parent);
createDomStructure(childItem, elm);
}
}

appendToElm?.appendChild(elm);
return elm;
}

Expand Down Expand Up @@ -172,7 +167,9 @@ export function getSize(elm: HTMLElement | undefined, mode: 'inner' | 'outer' |
size = elm[`client${pascalType}`];
break;
}
size = elm.getBoundingClientRect()[type];
if (!size || Number.isNaN(size)) {
size = elm.getBoundingClientRect()[type];
}
}

if (!size || Number.isNaN(size)) {
Expand Down
111 changes: 110 additions & 1 deletion packages/multiple-select-vanilla/test/prototype-pollution.test.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import assert from 'node:assert/strict';
import { after, test } from 'node:test';

import { MultipleSelectInstance, VirtualScroll, convertItemRowToHtml, createDomElement } from '../dist/index.js';
import { MultipleSelectInstance, VirtualScroll, convertItemRowToHtml, createDomElement, getSize } from '../dist/index.js';

const originalDocument = globalThis.document;
const originalElement = globalThis.Element;
const originalSanitizer = globalThis.Sanitizer;

after(() => {
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', () => {
Expand Down Expand Up @@ -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 = `<img src=x onerror="alert('xss')">&`;

assert.equal(sanitizer(maliciousHtml), '&lt;img src=x onerror=&quot;alert(&#39;xss&#39;)&quot;&gt;&amp;');
});

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);
Expand Down
4 changes: 2 additions & 2 deletions packages/multiple-select-vanilla/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading