From e2c21df1822f4b8bf249388811acbf2ae5697dc5 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Mon, 25 May 2026 14:49:44 +0400 Subject: [PATCH] Add ComponentMatcher to find methods --- docs/api/component.md | 14 ++- .../src/dom_components/model/Component.ts | 53 +++++++--- .../core/src/dom_components/model/types.ts | 5 + .../specs/dom_components/model/Component.ts | 98 +++++++++++++++++++ 4 files changed, 156 insertions(+), 14 deletions(-) diff --git a/docs/api/component.md b/docs/api/component.md index 5d8c281f3..9b6e4086c 100644 --- a/docs/api/component.md +++ b/docs/api/component.md @@ -177,13 +177,17 @@ also before rendering the component ### Parameters -* `type` **[String][1]** Component type +* `query` **([String][1] | Function)** Component type or matcher function +* `opts` **[Object][2]?** Search options +* `opts.max` **[Number][12]?** Maximum number of matches before exiting ### Examples ```javascript const allImages = component.findType('image'); console.log(allImages[0]) // prints the first found component + +const someComponents = component.findType((cmp) => cmp.getType() === 'something', { max: 2 }); ``` Returns **[Array][5]\** @@ -195,7 +199,7 @@ If no component is found, it returns `undefined`. ### Parameters -* `type` **[String][1]** Component type +* `query` **([String][1] | Function)** Component type or matcher function ### Examples @@ -204,6 +208,8 @@ const image = component.findFirstType('image'); if (image) { console.log(image); } + +const firstImage = component.findFirstType((cmp) => cmp.is('image')); ``` Returns **(Component | [undefined][11])** @@ -234,13 +240,15 @@ also before rendering the component ### Parameters -* `type` **[String][1]** Component type +* `query` **([String][1] | Function)** Component type or matcher function ### Examples ```javascript const Section = component.closestType('section'); console.log(Section); + +const namedSection = component.closestType((cmp) => cmp.getName() === 'Section'); ``` Returns **Component** Found component, otherwise `undefined` diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index 6463a8020..6b6f35129 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -62,6 +62,8 @@ import { ComponentAdd, ComponentDefinition, ComponentDefinitionDefined, + ComponentFindOptions, + ComponentMatcher, ComponentOptions, ComponentProperties, DragMode, @@ -79,6 +81,11 @@ export interface CheckIdOptions { updatedIds?: Record; } +const getComponentMatcher = (query: ComponentMatcher) => (isString(query) ? (cmp: Component) => cmp.is(query) : query); + +const getComponentFindMax = ({ max }: ComponentFindOptions = {}) => + typeof max === 'number' && isFinite(max) && max > 0 ? Math.max(1, Math.floor(max)) : undefined; + const escapeRegExp = (str: string) => { return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); }; @@ -620,19 +627,40 @@ export default class Component extends StyleableModel { * Find all inner components by component type. * The advantage of this method over `find` is that you can use it * also before rendering the component - * @param {String} type Component type + * @param {String|Function} query Component type or matcher function + * @param {Object} [opts={}] Search options + * @param {Number} [opts.max] Maximum number of matches before exiting * @returns {Array} * @example * const allImages = component.findType('image'); * console.log(allImages[0]) // prints the first found component + * const someComponents = component.findType((cmp) => cmp.getType() === 'something', { max: 2 }); */ - findType(type: string) { + findType(query: ComponentMatcher, opts: ComponentFindOptions = {}) { const result: Component[] = []; - const find = (components: Components) => + const matcher = getComponentMatcher(query); + const max = getComponentFindMax(opts); + const find = (components: Components) => { + let stop = false; + components.forEach((item) => { - item.is(type) && result.push(item); - find(item.components()); + if (stop) return; + + if (matcher(item)) { + result.push(item); + + if (max && result.length >= max) { + stop = true; + return; + } + } + + stop = find(item.components()) || stop; }); + + return stop; + }; + find(this.components()); return result; } @@ -640,16 +668,17 @@ export default class Component extends StyleableModel { /** * Find the first inner component by component type. * If no component is found, it returns `undefined`. - * @param {String} type Component type + * @param {String|Function} query Component type or matcher function * @returns {Component|undefined} * @example * const image = component.findFirstType('image'); * if (image) { * console.log(image); * } + * const firstImage = component.findFirstType((cmp) => cmp.is('image')); */ - findFirstType(type: string): Component | undefined { - return this.findType(type).at(0); + findFirstType(query: ComponentMatcher): Component | undefined { + return this.findType(query, { max: 1 }).at(0); } /** @@ -670,16 +699,18 @@ export default class Component extends StyleableModel { * Find the closest parent component by its type. * The advantage of this method over `closest` is that you can use it * also before rendering the component - * @param {String} type Component type + * @param {String|Function} query Component type or matcher function * @returns {Component} Found component, otherwise `undefined` * @example * const Section = component.closestType('section'); * console.log(Section); + * const namedSection = component.closestType((cmp) => cmp.getName() === 'Section'); */ - closestType(type: string) { + closestType(query: ComponentMatcher) { + const matcher = getComponentMatcher(query); let parent = this.parent(); - while (parent && !parent.is(type)) { + while (parent && !matcher(parent)) { parent = parent.parent(); } diff --git a/packages/core/src/dom_components/model/types.ts b/packages/core/src/dom_components/model/types.ts index 1ca71d09c..63689e747 100644 --- a/packages/core/src/dom_components/model/types.ts +++ b/packages/core/src/dom_components/model/types.ts @@ -17,6 +17,11 @@ import { ParsedNode } from '../../parser/types'; export type DragMode = 'translate' | 'absolute' | ''; export type DraggableDroppableFn = (source: Component, target: Component, index?: number) => boolean | void; +export type ComponentMatcher = string | ((cmp: Component) => boolean); + +export interface ComponentFindOptions { + max?: number; +} export interface AddComponentsOption extends AddOptions, OptionAsDocument {} diff --git a/packages/core/test/specs/dom_components/model/Component.ts b/packages/core/test/specs/dom_components/model/Component.ts index ef1694663..5f984efac 100644 --- a/packages/core/test/specs/dom_components/model/Component.ts +++ b/packages/core/test/specs/dom_components/model/Component.ts @@ -273,6 +273,63 @@ describe('Component', () => { expect(result.class).toEqual(undefined); }); + test('findType returns all matching components in depth-first order', () => { + const image1 = new ComponentImage({}, compOpts); + const group = new Component({ type: 'group' }, compOpts); + const text = new ComponentText({}, compOpts); + const image2 = new ComponentImage({}, compOpts); + + group.append([text, image2]); + obj.append([image1, group]); + + expect(obj.findType('image')).toEqual([image1, image2]); + }); + + test('findType accepts a predicate matcher', () => { + const target1 = new Component({ type: 'something' }, compOpts); + const group = new Component({ type: 'group' }, compOpts); + const target2 = new Component({ type: 'something' }, compOpts); + const text = new ComponentText({}, compOpts); + + group.append([text, target2]); + obj.append([target1, group]); + + expect(obj.findType((cmp) => cmp.getType() === 'something')).toEqual([target1, target2]); + }); + + test('findType supports max occurrences', () => { + const target1 = new Component({ type: 'something' }, compOpts); + const group = new Component({ type: 'group' }, compOpts); + const target2 = new Component({ type: 'something' }, compOpts); + const target3 = new Component({ type: 'something' }, compOpts); + + group.append(target2); + obj.append([target1, group, target3]); + + expect(obj.findType((cmp) => cmp.getType() === 'something', { max: 2 })).toEqual([target1, target2]); + }); + + test('findType stops traversing once max occurrences are reached', () => { + const target = new Component({ type: 'something' }, compOpts); + const nested = new Component({ type: 'nested' }, compOpts); + const sibling = new Component({ type: 'other' }, compOpts); + let calls = 0; + + target.append(nested); + obj.append([target, sibling]); + + const result = obj.findType( + (cmp) => { + calls++; + return cmp.getType() === 'something'; + }, + { max: 1 }, + ); + + expect(result).toEqual([target]); + expect(calls).toBe(1); + }); + test('findFirstType returns first component of specified type', () => { const image1 = new ComponentImage({}, compOpts); const text = new ComponentText({}, compOpts); @@ -299,6 +356,47 @@ describe('Component', () => { expect(result).toBeUndefined(); }); + test('findFirstType accepts a predicate matcher', () => { + const text = new ComponentText({}, compOpts); + const image = new ComponentImage({}, compOpts); + + obj.append([text, image]); + + expect(obj.findFirstType((cmp) => cmp.is('image'))).toBe(image); + }); + + test('findFirstType returns undefined for a missing predicate match', () => { + const text = new ComponentText({}, compOpts); + + obj.append(text); + + expect(obj.findFirstType((cmp) => cmp.is('image'))).toBeUndefined(); + }); + + test('closestType accepts a predicate matcher', () => { + const section = new Component({ type: 'section' }, compOpts); + const group = new Component({ type: 'group' }, compOpts); + const image = new ComponentImage({}, compOpts); + + group.append(image); + section.append(group); + obj.append(section); + + expect(image.closestType((cmp) => cmp.getType() === 'section')).toBe(section); + }); + + test('closestType still accepts a string matcher', () => { + const section = new Component({ type: 'section' }, compOpts); + const group = new Component({ type: 'group' }, compOpts); + const image = new ComponentImage({}, compOpts); + + group.append(image); + section.append(group); + obj.append(section); + + expect(image.closestType('section')).toBe(section); + }); + test('setAttributes', () => { obj.setAttributes({ id: 'test',