diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 4b0870063..af1687774 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -23,6 +23,7 @@ import { } from '../common/mixins/forms/form-value.js'; import { asArray, + equal, findElementFromEventPath, first, isEmpty, @@ -163,10 +164,10 @@ export default class IgcComboComponent< protected _navigation = new NavigationController(this, this._state); @queryAssignedElements({ slot: 'suffix' }) - protected inputSuffix!: Array; + protected inputSuffix!: HTMLElement[]; @queryAssignedElements({ slot: 'prefix' }) - protected inputPrefix!: Array; + protected inputPrefix!: HTMLElement[]; @query('[part="search-input"]') protected _searchInput!: IgcInputComponent; @@ -209,7 +210,11 @@ export default class IgcComboComponent< */ @property({ type: Boolean, reflect: true, attribute: 'single-select' }) public set singleSelect(value: boolean) { - this._singleSelect = value; + if (this._singleSelect === Boolean(value)) { + return; + } + + this._singleSelect = Boolean(value); this._selection.clear(); if (this.hasUpdated) { this.updateValue(); @@ -300,8 +305,10 @@ export default class IgcComboComponent< */ @property({ attribute: 'group-key' }) public set groupKey(value: Keys | undefined) { - this._groupKey = value; - this._state.runPipeline(); + if (this._groupKey !== value) { + this._groupKey = value; + this._state.runPipeline(); + } } public get groupKey() { @@ -316,8 +323,10 @@ export default class IgcComboComponent< */ @property({ attribute: 'group-sorting' }) public set groupSorting(value: GroupingDirection) { - this._groupSorting = value; - this._state.runPipeline(); + if (this._groupSorting !== value) { + this._groupSorting = value; + this._state.runPipeline(); + } } public get groupSorting() { @@ -334,8 +343,11 @@ export default class IgcComboComponent< */ @property({ type: Object, attribute: 'filtering-options' }) public set filteringOptions(value: Partial>) { - this._filteringOptions = { ...this._filteringOptions, ...value }; - this._state.runPipeline(); + const options = { ...this._filteringOptions, ...value }; + if (!equal(options, this._filteringOptions)) { + this._filteringOptions = options; + this._state.runPipeline(); + } } public get filteringOptions(): FilteringOptions { @@ -443,7 +455,9 @@ export default class IgcComboComponent< private _rootClickController = addRootClickHandler(this, { hideCallback: async () => { - if (!this.handleClosing()) return; + if (!this.handleClosing()) { + return; + } this.open = false; await this.updateComplete; @@ -668,7 +682,7 @@ export default class IgcComboComponent< /** Shows the list of options. */ public async show(): Promise { - return this._show(false); + return await this._show(false); } protected async _hide(emitEvent = true) { @@ -688,7 +702,7 @@ export default class IgcComboComponent< /** Hides the list of options. */ public async hide(): Promise { - return this._hide(false); + return await this._hide(false); } protected _toggle(emit = true) { @@ -697,7 +711,7 @@ export default class IgcComboComponent< /** Toggles the list of options. */ public async toggle(): Promise { - return this._toggle(false); + return await this._toggle(false); } private _getActiveDescendantId(index: number) { diff --git a/src/components/common/equal.spec.ts b/src/components/common/equal.spec.ts new file mode 100644 index 000000000..10ea4e3be --- /dev/null +++ b/src/components/common/equal.spec.ts @@ -0,0 +1,340 @@ +import { expect } from '@open-wc/testing'; +import { equal } from './util.js'; + +describe('equal', () => { + it('should return true for strictly equal primitive values', () => { + expect(equal(1, 1)).to.be.true; + expect(equal('hello', 'hello')).to.be.true; + expect(equal(true, true)).to.be.true; + expect(equal(null, null)).to.be.true; + expect(equal(undefined, undefined)).to.be.true; + expect(equal(Number.NaN, Number.NaN)).to.be.true; + }); + + it('should return false for strictly unequal primitive values', () => { + expect(equal(1, 2)).to.be.false; + expect(equal('hello', 'world')).to.be.false; + expect(equal(true, false)).to.be.false; + expect(equal(null, undefined)).to.be.false; + }); + + it('should return true for objects with the same keys and values (simple)', () => { + const obj1 = { a: 1, b: 'hello' }; + const obj2 = { a: 1, b: 'hello' }; + expect(equal(obj1, obj2)).to.be.true; + }); + + it('should return true for objects with the same keys and values (different order)', () => { + const obj1 = { a: 1, b: 'hello' }; + const obj2 = { b: 'hello', a: 1 }; + expect(equal(obj1, obj2)).to.be.true; + }); + + it('should return false for objects with different keys', () => { + const obj1 = { a: 1, b: 'hello' }; + const obj2 = { a: 1, c: 'hello' }; + expect(equal(obj1, obj2)).to.be.false; + }); + + it('should return false for objects with different values', () => { + const obj1 = { a: 1, b: 'hello' }; + const obj2 = { a: 2, b: 'hello' }; + expect(equal(obj1, obj2)).to.be.false; + }); + + it('should return true for nested objects with the same structure and values', () => { + const obj1 = { a: 1, b: { c: true, d: [1, 2] } }; + const obj2 = { a: 1, b: { c: true, d: [1, 2] } }; + expect(equal(obj1, obj2)).to.be.true; + }); + + it('should return false for nested objects with different values', () => { + const obj1 = { a: 1, b: { c: true, d: [1, 2] } }; + const obj2 = { a: 1, b: { c: false, d: [1, 2] } }; + expect(equal(obj1, obj2)).to.be.false; + }); + + it('should return true for arrays with the same elements and order', () => { + const arr1 = [1, 'hello', true]; + const arr2 = [1, 'hello', true]; + expect(equal(arr1, arr2)).to.be.true; + }); + + it('should return false for arrays with different elements', () => { + const arr1 = [1, 'hello', true]; + const arr2 = [1, 'world', true]; + expect(equal(arr1, arr2)).to.be.false; + }); + + it('should return false for arrays with different lengths', () => { + const arr1 = [1, 'hello']; + const arr2 = [1, 'hello', true]; + expect(equal(arr1, arr2)).to.be.false; + }); + + it('should return true for Maps with the same entries', () => { + const map1 = new Map([ + ['a', 1], + ['b', 'hello'], + ]); + const map2 = new Map([ + ['a', 1], + ['b', 'hello'], + ]); + expect(equal(map1, map2)).to.be.true; + }); + + it('should return false for Maps with different entries', () => { + const map1 = new Map([ + ['a', 1], + ['b', 'hello'], + ]); + const map2 = new Map([ + ['a', 2], + ['b', 'hello'], + ]); + expect(equal(map1, map2)).to.be.false; + }); + + it('should return false for Maps with different sizes', () => { + const map1 = new Map([['a', 1]]); + const map2 = new Map([ + ['a', 1], + ['b', 'hello'], + ]); + expect(equal(map1, map2)).to.be.false; + }); + + it('should return true for Sets with the same values', () => { + const set1 = new Set([1, 'hello']); + const set2 = new Set([1, 'hello']); + expect(equal(set1, set2)).to.be.true; + }); + + it('should return false for Sets with different values', () => { + const set1 = new Set([1, 'hello']); + const set2 = new Set([1, 'world']); + expect(equal(set1, set2)).to.be.false; + }); + + it('should return false for Sets with different sizes', () => { + const set1 = new Set([1]); + const set2 = new Set([1, 'hello']); + expect(equal(set1, set2)).to.be.false; + }); + + it('should return true for Dates with the same time value', () => { + const date1 = new Date('2025-04-22T12:00:00.000Z'); + const date2 = new Date('2025-04-22T12:00:00.000Z'); + expect(equal(date1, date2)).to.be.true; + }); + + it('should return false for Dates with different time values', () => { + const date1 = new Date('2025-04-22T12:00:00.000Z'); + const date2 = new Date('2025-04-22T12:01:00.000Z'); + expect(equal(date1, date2)).to.be.false; + }); + + it('should return true for RegExps with the same source and flags', () => { + const regex1 = /abc/g; + const regex2 = /abc/g; + expect(equal(regex1, regex2)).to.be.true; + }); + + it('should return false for RegExps with different source', () => { + const regex1 = /abc/g; + const regex2 = /abd/g; + expect(equal(regex1, regex2)).to.be.false; + }); + + it('should return false for RegExps with different flags', () => { + const regex1 = /abc/g; + const regex2 = /abc/i; + expect(equal(regex1, regex2)).to.be.false; + }); + + it('should handle simple circular references and return true', () => { + const obj1: any = { a: 1 }; + const obj2: any = { a: 1 }; + obj1.circular = obj1; + obj2.circular = obj2; + expect(equal(obj1, obj2)).to.be.true; + }); + + it('should handle nested circular references and return true', () => { + const obj1: any = { a: 1, b: {} }; + const obj2: any = { a: 1, b: {} }; + obj1.b.circular = obj1; + obj2.b.circular = obj2; + expect(equal(obj1, obj2)).to.be.true; + }); + + it('should return false for objects with different values despite circular references', () => { + const obj1: any = { a: 1 }; + const obj2: any = { a: 2 }; + obj1.circular = obj1; + obj2.circular = obj2; + expect(equal(obj1, obj2)).to.be.false; + }); + + it('should handle circular references in arrays and return true', () => { + const arr1: any[] = [1]; + const arr2: any[] = [1]; + arr1.push(arr1); + arr2.push(arr2); + expect(equal(arr1, arr2)).to.be.true; + }); + + it('should handle circular references in Maps (key and value) and return true', () => { + const map1: any = new Map(); + const map2: any = new Map(); + map1.set('self', map1); + map2.set('self', map2); + expect(equal(map1, map2)).to.be.true; + + const keyObj1: any = {}; + const keyObj2: any = {}; + const valObj1: any = {}; + const valObj2: any = {}; + const map3 = new Map([[keyObj1, valObj1]]); + const map4 = new Map([[keyObj2, valObj2]]); + keyObj1.ref = map3; + keyObj2.ref = map4; + valObj1.ref = map3; + valObj2.ref = map4; + expect(equal(map3, map4)).to.be.true; + }); + + it('should handle circular references in Sets and return true', () => { + const set1: any = new Set(); + const set2: any = new Set(); + set1.add(set1); + set2.add(set2); + expect(equal(set1, set2)).to.be.true; + + const obj1: any = {}; + const obj2: any = {}; + const set3 = new Set([obj1]); + const set4 = new Set([obj2]); + obj1.ref = set3; + obj2.ref = set4; + expect(equal(set3, set4)).to.be.true; + }); + + it('should return false for objects with different constructors', () => { + class ClassA { + value: number; + constructor(value: number) { + this.value = value; + } + } + class ClassB { + value: number; + constructor(value: number) { + this.value = value; + } + } + const instanceA = new ClassA(1); + const instanceB = new ClassB(1); + expect(equal(instanceA, instanceB)).to.be.false; + }); + + it('should handle objects with custom valueOf methods', () => { + const obj1 = { value: 1, valueOf: () => 1 }; + const obj2 = { value: 1, valueOf: () => 1 }; + expect(equal(obj1, obj2)).to.be.true; + + const obj3 = { value: 1, valueOf: () => 1 }; + const obj4 = { value: 1, valueOf: () => 2 }; + expect(equal(obj3, obj4)).to.be.false; + }); + + it('should handle objects with custom toString methods', () => { + const obj1 = { value: 'a', toString: () => 'a' }; + const obj2 = { value: 'a', toString: () => 'a' }; + expect(equal(obj1, obj2)).to.be.true; + + const obj3 = { value: 'a', toString: () => 'a' }; + const obj4 = { value: 'a', toString: () => 'b' }; + expect(equal(obj3, obj4)).to.be.false; + }); + + it('should return true for two empty objects', () => { + expect(equal({}, {})).to.be.true; + }); + + it('should return true for two empty arrays', () => { + expect(equal([], [])).to.be.true; + }); + + it('should return true for two empty Maps', () => { + expect(equal(new Map(), new Map())).to.be.true; + }); + + it('should return true for two empty Sets', () => { + expect(equal(new Set(), new Set())).to.be.true; + }); + + it('should return false for an empty object and a non-empty object', () => { + expect(equal({}, { a: 1 })).to.be.false; + expect(equal({ a: 1 }, {})).to.be.false; + }); + + it('should return false for an empty array and a non-empty array', () => { + expect(equal([], [1])).to.be.false; + expect(equal([1], [])).to.be.false; + }); + + it('should return false for an empty Map and a non-empty Map', () => { + expect(equal(new Map(), new Map([['a', 1]]))).to.be.false; + expect(equal(new Map([['a', 1]]), new Map())).to.be.false; + }); + + it('should return false for an empty Set and a non-empty Set', () => { + expect(equal(new Set(), new Set([1]))).to.be.false; + expect(equal(new Set([1]), new Set())).to.be.false; + }); + + it('should return false for objects with different numbers of keys', () => { + const obj1 = { a: 1 }; + const obj2 = { a: 1, b: 2 }; + expect(equal(obj1, obj2)).to.be.false; + expect(equal(obj2, obj1)).to.be.false; + }); + + it('should return true for nested empty structures', () => { + const obj1 = { a: {}, b: [] }; + const obj2 = { a: {}, b: [] }; + expect(equal(obj1, obj2)).to.be.true; + + const arr1 = [{}, []]; + const arr2 = [{}, []]; + expect(equal(arr1, arr2)).to.be.true; + + const map1 = new Map([['a', new Set()]]); + const map2 = new Map([['a', new Set()]]); + expect(equal(map1, map2)).to.be.true; + + const set1 = new Set([new Map()]); + const set2 = new Set([new Map()]); + expect(equal(set1, set2)).to.be.true; + }); + + it('should return false for nested structures with different emptiness', () => { + const obj1 = { a: {}, b: [1] }; + const obj2 = { a: {}, b: [] }; + expect(equal(obj1, obj2)).to.be.false; + + const arr1 = [{}, new Map([['a', 1]])]; + const arr2 = [{}, new Map()]; + expect(equal(arr1, arr2)).to.be.false; + + const map1 = new Map([['a', new Set([1])]]); + const map2 = new Map([['a', new Set()]]); + expect(equal(map1, map2)).to.be.false; + + const set1 = new Set([{}]); + const set2 = new Set([]); + expect(equal(set1, set2)).to.be.false; + }); +}); diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 17a0adb28..b52b10af5 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -33,10 +33,6 @@ export function numberInRangeInclusive( return value >= min && value <= max; } -export function sameObject(a: object, b: object) { - return JSON.stringify(a) === JSON.stringify(b); -} - /** * * Returns an element's offset relative to its parent. Similar to element.offsetTop and element.offsetLeft, except the @@ -361,6 +357,124 @@ export function roundByDPR(value: number): number { return Math.round(value * dpr) / dpr; } +export function isRegExp(value: unknown): value is RegExp { + return value != null && value.constructor === RegExp; +} + +export function equal(a: unknown, b: T, visited = new WeakSet()): boolean { + // Early return + if (Object.is(a, b)) { + return true; + } + + if (isObject(a) && isObject(b)) { + if (a.constructor !== b.constructor) { + return false; + } + + // Circular references + if (visited.has(a) && visited.has(b)) { + return true; + } + + visited.add(a); + visited.add(b); + + // RegExp + if (isRegExp(a) && isRegExp(b)) { + return a.source === b.source && a.flags === b.flags; + } + + // Maps + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) { + return false; + } + for (const [keyA, valueA] of a.entries()) { + let found = false; + for (const [keyB, valueB] of b.entries()) { + if (equal(keyA, keyB, visited) && equal(valueA, valueB, visited)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } + + // Sets + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) { + return false; + } + for (const valueA of a) { + let found = false; + for (const valueB of b) { + if (equal(valueA, valueB, visited)) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } + + // Arrays + if (Array.isArray(a) && Array.isArray(b)) { + const length = a.length; + if (length !== b.length) { + return false; + } + for (let i = 0; i < length; i++) { + if (!equal(a[i], b[i], visited)) { + return false; + } + } + return true; + } + + // toPrimitive + if (a.valueOf !== Object.prototype.valueOf) { + return a.valueOf() === b.valueOf(); + } + // Strings based + if (a.toString !== Object.prototype.toString) { + return a.toString() === b.toString(); + } + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return false; + } + + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) { + return false; + } + } + + for (const key of aKeys) { + if (!equal(a[key as keyof typeof a], b[key as keyof typeof b], visited)) { + return false; + } + } + + visited.delete(a); + visited.delete(b); + + return true; + } + + return false; +} + /** Required utility type for specific props */ export type RequiredProps = T & { [P in K]-?: T[P]; diff --git a/src/components/icon/icon.registry.ts b/src/components/icon/icon.registry.ts index dbd66a5ac..bf591a8d1 100644 --- a/src/components/icon/icon.registry.ts +++ b/src/components/icon/icon.registry.ts @@ -1,5 +1,5 @@ import type { Theme } from '../../theming/types.js'; -import { sameObject } from '../common/util.js'; +import { equal } from '../common/util.js'; import { iconReferences } from './icon-references.js'; import { IconsStateBroadcast } from './icon-state.broadcast.js'; import { internalIcons } from './internal-icons-lib.js'; @@ -66,7 +66,7 @@ class IconsRegistry { this.setIconRef({ alias, target: _target, - overwrite: !external && !sameObject(_ref, _target), + overwrite: !(external || equal(_ref, _target)), }); } } diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 9aabfad38..4f031ce1c 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -402,7 +402,7 @@ export const Form: Story = {