From 7e85ab6d6d3194889ba9a9c686536fe14f7df8f9 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 7 Oct 2025 13:08:52 +0300 Subject: [PATCH 1/5] feat: Added highlight container --- .../common/definitions/defineAllComponents.ts | 2 + src/components/common/util.ts | 15 +- src/components/date-time-input/date-util.ts | 8 +- src/components/highlight/highlight.spec.ts | 137 +++++++ src/components/highlight/highlight.ts | 116 ++++++ src/components/highlight/service.ts | 226 ++++++++++++ src/index.ts | 1 + stories/highlight.stories.ts | 344 ++++++++++++++++++ tsconfig.json | 2 +- 9 files changed, 843 insertions(+), 8 deletions(-) create mode 100644 src/components/highlight/highlight.spec.ts create mode 100644 src/components/highlight/highlight.ts create mode 100644 src/components/highlight/service.ts create mode 100644 stories/highlight.stories.ts diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 4d3e03119..a31a116a3 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -30,6 +30,7 @@ import IgcDropdownGroupComponent from '../../dropdown/dropdown-group.js'; import IgcDropdownHeaderComponent from '../../dropdown/dropdown-header.js'; import IgcDropdownItemComponent from '../../dropdown/dropdown-item.js'; import IgcExpansionPanelComponent from '../../expansion-panel/expansion-panel.js'; +import IgcHighlightComponent from '../../highlight/highlight.js'; import IgcIconComponent from '../../icon/icon.js'; import IgcInputComponent from '../../input/input.js'; import IgcListComponent from '../../list/list.js'; @@ -102,6 +103,7 @@ const allComponents: IgniteComponent[] = [ IgcDividerComponent, IgcSwitchComponent, IgcExpansionPanelComponent, + IgcHighlightComponent, IgcIconComponent, IgcInputComponent, IgcListHeaderComponent, diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 6088fff86..440de5f00 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -379,7 +379,7 @@ export function roundByDPR(value: number): number { } export function scrollIntoView( - element?: HTMLElement, + element?: HTMLElement | null, config?: ScrollIntoViewOptions ): void { if (!element) { @@ -516,6 +516,19 @@ export function equal(a: unknown, b: T, visited = new WeakSet()): boolean { return false; } +/** + * Escapes any potential regex syntax characters in a string, and returns a new string + * that can be safely used as a literal pattern for the `RegExp()` constructor. + * + * @remarks + * Substitute with `RegExp.escape` once it has enough support: + * + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape#browser_compatibility + */ +export function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** Required utility type for specific props */ export type RequiredProps = T & { [P in K]-?: T[P]; diff --git a/src/components/date-time-input/date-util.ts b/src/components/date-time-input/date-util.ts index 04672f142..8ea071927 100644 --- a/src/components/date-time-input/date-util.ts +++ b/src/components/date-time-input/date-util.ts @@ -1,5 +1,5 @@ import { parseISODate } from '../calendar/helpers.js'; -import { clamp } from '../common/util.js'; +import { clamp, escapeRegex } from '../common/util.js'; export enum FormatDesc { Numeric = 'numeric', @@ -847,13 +847,9 @@ export abstract class DateTimeUtil { ); } - private static escapeRegExp(string: string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - private static trimEmptyPlaceholders(value: string, prompt?: string): string { const result = value.replace( - new RegExp(DateTimeUtil.escapeRegExp(prompt ?? '_'), 'g'), + new RegExp(escapeRegex(prompt ?? '_'), 'g'), '' ); return result; diff --git a/src/components/highlight/highlight.spec.ts b/src/components/highlight/highlight.spec.ts new file mode 100644 index 000000000..9209bb7de --- /dev/null +++ b/src/components/highlight/highlight.spec.ts @@ -0,0 +1,137 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; + +import { defineComponents } from '../common/definitions/defineComponents.js'; +import IgcHighlightComponent from './highlight.js'; + +describe('Highlight', () => { + before(() => defineComponents(IgcHighlightComponent)); + + let highlight: IgcHighlightComponent; + + function createHighlightWithInitialMatch() { + return html` + Lorem ipsum dolor sit amet consectetur adipisicing elit. Sapiente in + recusandae aliquam placeat! Saepe hic reiciendis quae, dolorum totam ab + mollitia, tempora excepturi blanditiis repellat dolore nemo cumque illum + quas. + `; + } + + function createHighlight() { + return html` + Lorem ipsum dolor sit amet consectetur adipisicing elit. Sapiente in + recusandae aliquam placeat! Saepe hic reiciendis quae, dolorum totam ab + mollitia, tempora excepturi blanditiis repellat dolore nemo cumque illum + quas. + `; + } + + describe('Initial render', () => { + beforeEach(async () => { + highlight = await fixture(createHighlightWithInitialMatch()); + }); + + it('is correctly matched', async () => { + expect(highlight.size).to.equal(1); + }); + }); + + describe('DOM', () => { + beforeEach(async () => { + highlight = await fixture(createHighlight()); + }); + + it('is defined', async () => { + expect(highlight).to.not.be.undefined; + }); + + it('is accessible', async () => { + await expect(highlight).shadowDom.to.be.accessible(); + await expect(highlight).lightDom.to.be.accessible(); + }); + }); + + describe('API', () => { + beforeEach(async () => { + highlight = await fixture(createHighlight()); + }); + + it('matches on changing `search` value', async () => { + expect(highlight.size).to.equal(0); + + highlight.searchText = 'lorem'; + await elementUpdated(highlight); + + expect(highlight.size).to.equal(1); + + highlight.searchText = ''; + await elementUpdated(highlight); + + expect(highlight.size).to.equal(0); + }); + + it('matches with case sensitivity', async () => { + highlight.caseSensitive = true; + highlight.searchText = 'lorem'; + await elementUpdated(highlight); + + expect(highlight.size).to.equal(0); + + highlight.searchText = 'Lorem'; + await elementUpdated(highlight); + + expect(highlight.size).to.equal(1); + }); + + it('moves to the next match when `next()` is invoked', async () => { + highlight.searchText = 'e'; + await elementUpdated(highlight); + + expect(highlight.size).greaterThan(0); + expect(highlight.current).to.equal(0); + + highlight.next(); + expect(highlight.current).to.equal(1); + }); + + it('moves to the previous when `previous()` is invoked', async () => { + highlight.searchText = 'e'; + await elementUpdated(highlight); + + expect(highlight.size).greaterThan(0); + expect(highlight.current).to.equal(0); + + // Wrap around to the last one + highlight.previous(); + expect(highlight.current).to.equal(highlight.size - 1); + }); + + it('setActive called', async () => { + highlight.searchText = 'e'; + await elementUpdated(highlight); + + highlight.setActive(15); + expect(highlight.current).to.equal(15); + }); + + it('refresh called', async () => { + highlight.searchText = 'lorem'; + await elementUpdated(highlight); + + expect(highlight.size).to.equal(1); + + const node = document.createElement('div'); + node.textContent = 'Lorem '.repeat(9); + + highlight.append(node); + highlight.search(); + + expect(highlight.size).to.equal(10); + + node.remove(); + highlight.search(); + + expect(highlight.size).to.equal(1); + }); + }); +}); diff --git a/src/components/highlight/highlight.ts b/src/components/highlight/highlight.ts new file mode 100644 index 000000000..1fc4fb4bd --- /dev/null +++ b/src/components/highlight/highlight.ts @@ -0,0 +1,116 @@ +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { + createHighlightController, + type HighlightNavigation, +} from './service.js'; + +/** + * The highlight component provides a way for efficient searching and highlighting of + * text projected into it. + * + * @element igc-highlight + * + * @slot - The default slot of the component. + * + * @cssproperty --resting-color - The text color for a highlighted text node. + * @cssproperty --resting-background - The background color for a highlighted text node. + * @cssproperty --active-color - The text color for the active highlighted text node. + * @cssproperty --active-background - The background color for the active highlighted text node. + */ +export default class IgcHighlightComponent extends LitElement { + public static readonly tagName = 'igc-highlight'; + + public static override styles = css` + :host { + display: contents; + } + `; + + /* blazorSuppress */ + public static register(): void { + registerComponent(IgcHighlightComponent); + } + + private readonly _service = createHighlightController(this); + + private _caseSensitive = false; + private _searchText = ''; + + /** + * Whether to match the searched text with case sensitivity in mind. + * @attr case-sensitive + */ + @property({ type: Boolean, reflect: true, attribute: 'case-sensitive' }) + public set caseSensitive(value: boolean) { + this._caseSensitive = value; + this.search(); + } + + public get caseSensitive(): boolean { + return this._caseSensitive; + } + + /** + * The string to search and highlight in the DOM content of the component. + * @attr search-text + */ + @property({ attribute: 'search-text' }) + public set searchText(value: string) { + this._searchText = value; + this.search(); + } + + public get searchText(): string { + return this._searchText; + } + + /** The number of matches. */ + public get size(): number { + return this._service.size; + } + + /** The index of the currently active match. */ + public get current(): number { + return this._service.current; + } + + /** Moves the active state to the next match. */ + public next(options?: HighlightNavigation): void { + this._service.next(options); + } + + /** Moves the active state to the previous match. */ + public previous(options?: HighlightNavigation): void { + this._service.previous(options); + } + + /** Moves the active state to the given index. */ + public setActive(index: number, options?: HighlightNavigation): void { + this._service.setActive(index, options); + } + + /** + * Executes the highlight logic again based on the current `searchText` and + * `caseSensitive` values. + * + * Useful when the slotted content is dynamic. + */ + public search(): void { + if (this.hasUpdated) { + this._service.clear(); + this._service.find(this.searchText); + } + } + + protected override render() { + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-highlight': IgcHighlightComponent; + } +} diff --git a/src/components/highlight/service.ts b/src/components/highlight/service.ts new file mode 100644 index 000000000..d2ed12446 --- /dev/null +++ b/src/components/highlight/service.ts @@ -0,0 +1,226 @@ +import { isServer, type ReactiveController } from 'lit'; +import { + escapeRegex, + first, + iterNodes, + scrollIntoView, + wrap, +} from '../common/util.js'; +import type IgcHighlightComponent from './highlight.js'; + +type Match = { node: Node; indices: [start: number, end: number] }; + +/** + * Options for controlling navigation behavior when moving the active highlight. + */ +export type HighlightNavigation = { + /** If true, prevents the component from scrolling the new active match into view. */ + preventScroll?: boolean; +}; + +let nextKey = 0; + +function* matchText( + nodes: IterableIterator, + regexp: RegExp +): Generator { + for (const node of nodes) { + if (node.textContent) { + for (const match of node.textContent.matchAll(regexp)) { + yield { node, indices: first(match.indices!) } satisfies Match; + } + } + } +} + +class HighlightService implements ReactiveController { + //#region Private properties + + private readonly _host: IgcHighlightComponent; + + private readonly _key = `igc-highlight-${nextKey++}`; + private readonly _activeKey = `${this._key}-active`; + + private readonly _styles = ` + ::highlight(${this._key}) { + background-color: var(--resting-background, hsla(var(--ig-gray-300), var(--ig-gray-a))); + color: var(--resting-color, white); + } + ::highlight(${this._activeKey}) { + background-color: var(--active-background, hsla(333deg , calc( 78% * 1.23), calc( 49% * 1.34), 1)); + color: var(--active-color, white); + }` as const; + + private readonly _styleSheet!: CSSStyleSheet; + + private _highlight!: Highlight; + private _activeHighlight!: Highlight; + + private _current = 0; + + //#endregion + + //#region Public properties + + public get size(): number { + return this._highlight.size; + } + + public get current(): number { + return this._current; + } + + //#endregion + + constructor(host: IgcHighlightComponent) { + this._host = host; + this._host.addController(this); + + if (!isServer) { + this._styleSheet = new CSSStyleSheet(); + this._styleSheet.replaceSync(this._styles); + } + } + + //#region Controller life-cycle + + /** @internal */ + public hostConnected(): void { + this._createHighlightEntries(); + this._addStylesheet(); + this.find(this._host.searchText); + } + + /** @internal */ + public hostDisconnected(): void { + this._removeHighlightEntries(); + this._removeStyleSheet(); + } + + //#endregion + + //#region Private methods + + private _addStylesheet(): void { + const root = this._host.renderRoot as ShadowRoot; + root.adoptedStyleSheets.push(this._styleSheet); + } + + private _removeStyleSheet(): void { + const root = this._host.renderRoot as ShadowRoot; + + root.adoptedStyleSheets = root.adoptedStyleSheets.filter( + (sheet) => sheet !== this._styleSheet + ); + } + + private _createHighlightEntries(): void { + this._highlight = new Highlight(); + this._highlight.priority = 0; + + this._activeHighlight = new Highlight(); + this._activeHighlight.priority = 1; + + CSS.highlights.set(this._key, this._highlight); + CSS.highlights.set(this._activeKey, this._activeHighlight); + } + + private _removeHighlightEntries(): void { + CSS.highlights.delete(this._key); + CSS.highlights.delete(this._activeKey); + } + + private _createRegex(value: string): RegExp { + return new RegExp( + `${escapeRegex(value)}`, + this._host.caseSensitive ? 'dg' : 'dgi' + ); + } + + private _getRangeByIndex(index: number): Range { + return this._highlight.values().drop(index).next().value as Range; + } + + private _updateActiveHighlight(): void { + if (this.size) { + this._activeHighlight.add( + this._getRangeByIndex(this._current).cloneRange() + ); + } + } + + private _goTo(index: number, options?: HighlightNavigation): void { + if (!this.size) { + return; + } + + this._current = wrap(0, this.size - 1, index); + const range = this._getRangeByIndex(this._current); + + this._activeHighlight.clear(); + this._activeHighlight.add(range.cloneRange()); + + if (!options?.preventScroll) { + scrollIntoView(range.commonAncestorContainer.parentElement, { + block: 'center', + inline: 'center', + }); + } + } + + //#endregion + + //#region Public methods + + public find(value: string): void { + if (!value) { + return; + } + + const nodes = iterNodes(this._host, { + show: 'SHOW_TEXT', + filter: (node) => !!node.textContent, + }); + + const iterator = matchText(nodes, this._createRegex(value)); + + for (const { node, indices } of iterator) { + const range = new Range(); + range.setStart(node, indices[0]); + range.setEnd(node, indices[1]); + this._highlight.add(range); + } + + this._updateActiveHighlight(); + } + + /** Moves the active state to the next match. */ + public next(options?: HighlightNavigation): void { + this._goTo(this._current + 1, options); + } + + /** Moves the active state to the previous match. */ + public previous(options?: HighlightNavigation): void { + this._goTo(this._current - 1, options); + } + + /** Moves the active state to the given index. */ + public setActive(index: number, options?: HighlightNavigation): void { + this._goTo(index, options); + } + + /** Clears highlight collections. */ + public clear(): void { + this._activeHighlight.clear(); + this._highlight.clear(); + this._current = 0; + } + + //#endregion +} + +export function createHighlightController( + host: IgcHighlightComponent +): HighlightService { + return new HighlightService(host); +} diff --git a/src/index.ts b/src/index.ts index 01af701f4..1fc210cb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,7 @@ export { default as IgcTreeComponent } from './components/tree/tree.js'; export { default as IgcTreeItemComponent } from './components/tree/tree-item.js'; export { default as IgcStepperComponent } from './components/stepper/stepper.js'; export { default as IgcStepComponent } from './components/stepper/step.js'; +export { default as IgcHighlightComponent } from './components/highlight/highlight.js'; export { default as IgcTooltipComponent } from './components/tooltip/tooltip.js'; // definitions diff --git a/stories/highlight.stories.ts b/stories/highlight.stories.ts new file mode 100644 index 000000000..95fa2571d --- /dev/null +++ b/stories/highlight.stories.ts @@ -0,0 +1,344 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; + +import { asyncAppend } from 'lit/directives/async-append.js'; +import { + IgcDividerComponent, + IgcExpansionPanelComponent, + IgcHighlightComponent, + IgcIconButtonComponent, + IgcInputComponent, + defineComponents, +} from 'igniteui-webcomponents'; + +defineComponents( + IgcIconButtonComponent, + IgcExpansionPanelComponent, + IgcInputComponent, + IgcHighlightComponent, + IgcDividerComponent +); + +// region default +const metadata: Meta = { + title: 'Highlight', + component: 'igc-highlight', + parameters: { + docs: { + description: { + component: + 'The highlight component provides a way for efficient searching and highlighting of\ntext projected into it.', + }, + }, + }, + argTypes: { + caseSensitive: { + type: 'boolean', + description: + 'Whether to match the searched text with case sensitivity in mind.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + searchText: { + type: 'string', + description: + 'The string to search and highlight in the DOM content of the component.', + control: 'text', + }, + }, + args: { caseSensitive: false }, +}; + +export default metadata; + +interface IgcHighlightArgs { + /** Whether to match the searched text with case sensitivity in mind. */ + caseSensitive: boolean; + /** The string to search and highlight in the DOM content of the component. */ + searchText: string; +} +type Story = StoryObj; + +// endregion + +function* generateText() { + const words = [ + 'lorem', + 'ipsum', + 'dolor', + 'sit', + 'amet', + 'consectetur', + 'adipiscing', + 'elit', + 'sed', + 'do', + 'eiusmod', + 'tempor', + 'incididunt', + 'ut', + 'labore', + 'et', + 'dolore', + 'magna', + 'aliqua', + 'performance', + 'synergy', + 'cloud-based', + 'blazing', + 'fast', + 'artisan', + 'AI', + 'LLM', + ]; + + for (let i = 0; i < 25; i++) { + const sentences: string[] = []; + for (let j = 0; j < 10; j++) { + const numberOfWords = Math.floor(Math.random() * 10) + 42; + for (let k = 0; k < numberOfWords; k++) { + sentences.push(words[Math.floor(Math.random() * words.length)]); + } + yield sentences.join(' '); + } + } +} + +async function* generateParagraphs() { + for (const each of generateText()) { + yield each; + await new Promise((r) => setTimeout(() => r())); + } +} + +function updateStatus(current: number, matches: number) { + document.getElementById('result')!.textContent = matches + ? `Showing: ${current} of ${matches} result${matches === 1 ? '' : 's'}` + : ''; +} + +function onInputSearch({ detail }: CustomEvent) { + const highlight = document.querySelector(IgcHighlightComponent.tagName)!; + highlight.searchText = detail; + + updateStatus(highlight.current + 1, highlight.size); +} + +function prev() { + const highlight = document.querySelector(IgcHighlightComponent.tagName)!; + highlight.previous(); + + updateStatus(highlight.current + 1, highlight.size); +} + +function next() { + const highlight = document.querySelector(IgcHighlightComponent.tagName)!; + highlight.next(); + updateStatus(highlight.current + 1, highlight.size); +} + +const SearchInput = (variant: 'light' | 'dark') => html` + +
+ + + +

+
+ +
+`; + +export const Default: Story = { + render: () => html` + + +

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quae doloribus + odit id excepturi ipsum provident eaque dignissimos beatae! Rerum vero + distinctio libero, quasi magni quod natus nesciunt doloremque temporibus + voluptate? +

+
+ + +

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quae doloribus + odit id excepturi ipsum provident eaque dignissimos beatae! Rerum vero + distinctio libero, quasi magni quod natus nesciunt doloremque temporibus + voluptate? +

+
+ `, +}; + +export const SearchUI: Story = { + render: (args, { globals: { variant } }) => html` + + + ${SearchInput(variant)} + + +

Document Object Model

+

+ Source: + Wikipedia +

+ +

Overview

+
+

+ The Document Object Model (DOM) is a cross-platform and + language-independent interface that treats an HTML or XML document + as a tree structure wherein each node is an object representing a + part of the document. The DOM represents a document with a logical + tree. Each branch of the tree ends in a node, and each node contains + objects. DOM methods allow programmatic access to the tree; with + them one can change the structure, style or content of a document. + Nodes can have event handlers (also known as event listeners) + attached to them. Once an event is triggered, the event handlers get + executed. +

+

+ The principal standardization of the DOM was handled by the World + Wide Web Consortium (W3C), which last developed a recommendation in + 2004. WHATWG took over the development of the standard, publishing + it as a living document. The W3C now publishes stable snapshots of + the WHATWG standard. +

+

In HTML DOM (Document Object Model), every element is a node:

+
    +
  • A document is a document node.
  • +
  • All HTML elements are element nodes.
  • +
  • All HTML attributes are attribute nodes.
  • +
  • Text inserted into HTML elements are text nodes.
  • +
  • Comments are comment nodes.
  • +
+
+
+ + +

History

+
+

+ The history of the Document Object Model is intertwined with the + history of the "browser wars" of the late 1990s between Netscape + Navigator and Microsoft Internet Explorer, as well as with that of + JavaScript and JScript, the first scripting languages to be widely + implemented in the JavaScript engines of web browsers. +

+

+ JavaScript was released by Netscape Communications in 1995 within + Netscape Navigator 2.0. Netscape's competitor, Microsoft, released + Internet Explorer 3.0 the following year with a reimplementation of + JavaScript called JScript. JavaScript and JScript let web developers + create web pages with client-side interactivity. The limited + facilities for detecting user-generated events and modifying the + HTML document in the first generation of these languages eventually + became known as "DOM Level 0" or "Legacy DOM." No independent + standard was developed for DOM Level 0, but it was partly described + in the specifications for HTML 4. +

+

+ Legacy DOM was limited in the kinds of elements that could be + accessed. Form, link and image elements could be referenced with a + hierarchical name that began with the root document object. A + hierarchical name could make use of either the names or the + sequential index of the traversed elements. For example, a form + input element could be accessed as either document.myForm.myInput or + document.forms[0].elements[0]. +

+

+ The Legacy DOM enabled client-side form validation and simple + interface interactivity like creating tooltips. +

+

+ In 1997, Netscape and Microsoft released version 4.0 of Netscape + Navigator and Internet Explorer respectively, adding support for + Dynamic HTML (DHTML) functionality enabling changes to a loaded HTML + document. DHTML required extensions to the rudimentary document + object that was available in the Legacy DOM implementations. + Although the Legacy DOM implementations were largely compatible + since JScript was based on JavaScript, the DHTML DOM extensions were + developed in parallel by each browser maker and remained + incompatible. These versions of the DOM became known as the + "Intermediate DOM". +

+

+ After the standardization of ECMAScript, the W3C DOM Working Group + began drafting a standard DOM specification. The completed + specification, known as "DOM Level 1", became a W3C Recommendation + in late 1998. By 2005, large parts of W3C DOM were well-supported by + common ECMAScript-enabled browsers, including Internet Explorer 6 + (from 2001), Opera, Safari and Gecko-based browsers (like Mozilla, + Firefox, SeaMonkey and Camino). +

+
+
+
+ `, +}; + +export const Performance: Story = { + render: (_, context) => html` + + + ${SearchInput(context.globals.variant)} + ${asyncAppend( + generateParagraphs(), + (p) => html`

${p}

` + )}
+ `, +}; diff --git a/tsconfig.json b/tsconfig.json index dde72c013..35d225caa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "es2022", "module": "NodeNext", - "lib": ["es2023", "DOM", "DOM.Iterable"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "outDir": "dist", "rootDir": "./", "declaration": true, From dea4b8ff2c4620b7b0a3972fbcd1595d0d2e008d Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 11 Mar 2026 18:06:57 +0200 Subject: [PATCH 2/5] chore: Updated Storybook Highlight Story --- stories/highlight.stories.ts | 471 +++++++++++++++++++---------------- 1 file changed, 250 insertions(+), 221 deletions(-) diff --git a/stories/highlight.stories.ts b/stories/highlight.stories.ts index 95fa2571d..59a2b741b 100644 --- a/stories/highlight.stories.ts +++ b/stories/highlight.stories.ts @@ -1,7 +1,8 @@ -import type { Meta, StoryObj } from '@storybook/web-components'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { createRef, ref } from 'lit/directives/ref.js'; -import { asyncAppend } from 'lit/directives/async-append.js'; import { IgcDividerComponent, IgcExpansionPanelComponent, @@ -10,6 +11,7 @@ import { IgcInputComponent, defineComponents, } from 'igniteui-webcomponents'; +import { disableStoryControls } from './story.js'; defineComponents( IgcIconButtonComponent, @@ -61,7 +63,69 @@ type Story = StoryObj; // endregion -function* generateText() { +function createSearchController() { + const highlightRef = createRef(); + const statusRef = createRef(); + + function updateStatus() { + const highlight = highlightRef.value; + const status = statusRef.value; + if (!highlight || !status) return; + + status.textContent = highlight.size + ? `${highlight.current + 1} of ${highlight.size} match${highlight.size === 1 ? '' : 'es'}` + : ''; + } + + function onInput({ detail }: CustomEvent) { + if (!highlightRef.value) return; + highlightRef.value.searchText = detail; + updateStatus(); + } + + function prev() { + highlightRef.value?.previous(); + updateStatus(); + } + + function next() { + highlightRef.value?.next(); + updateStatus(); + } + + return { highlightRef, statusRef, onInput, prev, next }; +} + +function SearchBar(controller: ReturnType) { + const { statusRef, onInput, prev, next } = controller; + + return html` + + `; +} + +function generateParagraphs(count: number): string[] { const words = [ 'lorem', 'ipsum', @@ -82,109 +146,52 @@ function* generateText() { 'dolore', 'magna', 'aliqua', - 'performance', - 'synergy', - 'cloud-based', - 'blazing', - 'fast', - 'artisan', - 'AI', - 'LLM', ]; - for (let i = 0; i < 25; i++) { - const sentences: string[] = []; - for (let j = 0; j < 10; j++) { - const numberOfWords = Math.floor(Math.random() * 10) + 42; - for (let k = 0; k < numberOfWords; k++) { - sentences.push(words[Math.floor(Math.random() * words.length)]); - } - yield sentences.join(' '); - } - } -} - -async function* generateParagraphs() { - for (const each of generateText()) { - yield each; - await new Promise((r) => setTimeout(() => r())); - } -} - -function updateStatus(current: number, matches: number) { - document.getElementById('result')!.textContent = matches - ? `Showing: ${current} of ${matches} result${matches === 1 ? '' : 's'}` - : ''; + return Array.from({ length: count }, () => { + const wordCount = Math.floor(Math.random() * 30) + 40; + return Array.from( + { length: wordCount }, + () => words[Math.floor(Math.random() * words.length)] + ).join(' '); + }); } -function onInputSearch({ detail }: CustomEvent) { - const highlight = document.querySelector(IgcHighlightComponent.tagName)!; - highlight.searchText = detail; - - updateStatus(highlight.current + 1, highlight.size); -} - -function prev() { - const highlight = document.querySelector(IgcHighlightComponent.tagName)!; - highlight.previous(); - - updateStatus(highlight.current + 1, highlight.size); -} - -function next() { - const highlight = document.querySelector(IgcHighlightComponent.tagName)!; - highlight.next(); - updateStatus(highlight.current + 1, highlight.size); -} - -const SearchInput = (variant: 'light' | 'dark') => html` - -
- - - -

-
- -
-`; - export const Default: Story = { + render: (args) => html` + +

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quae doloribus + odit id excepturi ipsum provident eaque dignissimos beatae! Rerum vero + distinctio libero, quasi magni quod natus nesciunt doloremque temporibus + voluptate? +

+
+ `, + args: { searchText: 'lorem' }, +}; + +export const CustomStyling: Story = { + argTypes: disableStoryControls(metadata), render: () => html` - +

Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quae doloribus odit id excepturi ipsum provident eaque dignissimos beatae! Rerum vero @@ -193,7 +200,7 @@ export const Default: Story = {

- +

Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quae doloribus odit id excepturi ipsum provident eaque dignissimos beatae! Rerum vero @@ -205,140 +212,162 @@ export const Default: Story = { }; export const SearchUI: Story = { - render: (args, { globals: { variant } }) => html` - + render: (args) => { + const controller = createSearchController(); - ${SearchInput(variant)} + return html` + - -

Document Object Model

-

- Source: - Wikipedia -

- -

Overview

-
-

- The Document Object Model (DOM) is a cross-platform and - language-independent interface that treats an HTML or XML document - as a tree structure wherein each node is an object representing a - part of the document. The DOM represents a document with a logical - tree. Each branch of the tree ends in a node, and each node contains - objects. DOM methods allow programmatic access to the tree; with - them one can change the structure, style or content of a document. - Nodes can have event handlers (also known as event listeners) - attached to them. Once an event is triggered, the event handlers get - executed. -

-

- The principal standardization of the DOM was handled by the World - Wide Web Consortium (W3C), which last developed a recommendation in - 2004. WHATWG took over the development of the standard, publishing - it as a living document. The W3C now publishes stable snapshots of - the WHATWG standard. -

-

In HTML DOM (Document Object Model), every element is a node:

-
    -
  • A document is a document node.
  • -
  • All HTML elements are element nodes.
  • -
  • All HTML attributes are attribute nodes.
  • -
  • Text inserted into HTML elements are text nodes.
  • -
  • Comments are comment nodes.
  • -
-
-
+ ${SearchBar(controller)} - -

History

-
-

- The history of the Document Object Model is intertwined with the - history of the "browser wars" of the late 1990s between Netscape - Navigator and Microsoft Internet Explorer, as well as with that of - JavaScript and JScript, the first scripting languages to be widely - implemented in the JavaScript engines of web browsers. -

-

- JavaScript was released by Netscape Communications in 1995 within - Netscape Navigator 2.0. Netscape's competitor, Microsoft, released - Internet Explorer 3.0 the following year with a reimplementation of - JavaScript called JScript. JavaScript and JScript let web developers - create web pages with client-side interactivity. The limited - facilities for detecting user-generated events and modifying the - HTML document in the first generation of these languages eventually - became known as "DOM Level 0" or "Legacy DOM." No independent - standard was developed for DOM Level 0, but it was partly described - in the specifications for HTML 4. -

-

- Legacy DOM was limited in the kinds of elements that could be - accessed. Form, link and image elements could be referenced with a - hierarchical name that began with the root document object. A - hierarchical name could make use of either the names or the - sequential index of the traversed elements. For example, a form - input element could be accessed as either document.myForm.myInput or - document.forms[0].elements[0]. -

-

- The Legacy DOM enabled client-side form validation and simple - interface interactivity like creating tooltips. -

-

- In 1997, Netscape and Microsoft released version 4.0 of Netscape - Navigator and Internet Explorer respectively, adding support for - Dynamic HTML (DHTML) functionality enabling changes to a loaded HTML - document. DHTML required extensions to the rudimentary document - object that was available in the Legacy DOM implementations. - Although the Legacy DOM implementations were largely compatible - since JScript was based on JavaScript, the DHTML DOM extensions were - developed in parallel by each browser maker and remained - incompatible. These versions of the DOM became known as the - "Intermediate DOM". -

-

- After the standardization of ECMAScript, the W3C DOM Working Group - began drafting a standard DOM specification. The completed - specification, known as "DOM Level 1", became a W3C Recommendation - in late 1998. By 2005, large parts of W3C DOM were well-supported by - common ECMAScript-enabled browsers, including Internet Explorer 6 - (from 2001), Opera, Safari and Gecko-based browsers (like Mozilla, - Firefox, SeaMonkey and Camino). -

-
-
-
- `, + +

Document Object Model

+

+ Source: + Wikipedia +

+ +

Overview

+
+

+ The Document Object Model (DOM) is a cross-platform and + language-independent interface that treats an HTML or XML document + as a tree structure wherein each node is an object representing a + part of the document. The DOM represents a document with a logical + tree. Each branch of the tree ends in a node, and each node + contains objects. DOM methods allow programmatic access to the + tree; with them one can change the structure, style or content of + a document. Nodes can have event handlers (also known as event + listeners) attached to them. Once an event is triggered, the event + handlers get executed. +

+

+ The principal standardization of the DOM was handled by the World + Wide Web Consortium (W3C), which last developed a recommendation + in 2004. WHATWG took over the development of the standard, + publishing it as a living document. The W3C now publishes stable + snapshots of the WHATWG standard. +

+

In HTML DOM (Document Object Model), every element is a node:

+
    +
  • A document is a document node.
  • +
  • All HTML elements are element nodes.
  • +
  • All HTML attributes are attribute nodes.
  • +
  • Text inserted into HTML elements are text nodes.
  • +
  • Comments are comment nodes.
  • +
+
+
+ + +

History

+
+

+ The history of the Document Object Model is intertwined with the + history of the "browser wars" of the late 1990s between Netscape + Navigator and Microsoft Internet Explorer, as well as with that of + JavaScript and JScript, the first scripting languages to be widely + implemented in the JavaScript engines of web browsers. +

+

+ JavaScript was released by Netscape Communications in 1995 within + Netscape Navigator 2.0. Netscape's competitor, Microsoft, released + Internet Explorer 3.0 the following year with a reimplementation + of JavaScript called JScript. JavaScript and JScript let web + developers create web pages with client-side interactivity. The + limited facilities for detecting user-generated events and + modifying the HTML document in the first generation of these + languages eventually became known as "DOM Level 0" or "Legacy + DOM." No independent standard was developed for DOM Level 0, but + it was partly described in the specifications for HTML 4. +

+

+ Legacy DOM was limited in the kinds of elements that could be + accessed. Form, link and image elements could be referenced with a + hierarchical name that began with the root document object. A + hierarchical name could make use of either the names or the + sequential index of the traversed elements. For example, a form + input element could be accessed as either document.myForm.myInput + or + document.forms[0].elements[0]. +

+

+ The Legacy DOM enabled client-side form validation and simple + interface interactivity like creating tooltips. +

+

+ In 1997, Netscape and Microsoft released version 4.0 of Netscape + Navigator and Internet Explorer respectively, adding support for + Dynamic HTML (DHTML) functionality enabling changes to a loaded + HTML document. DHTML required extensions to the rudimentary + document object that was available in the Legacy DOM + implementations. Although the Legacy DOM implementations were + largely compatible since JScript was based on JavaScript, the + DHTML DOM extensions were developed in parallel by each browser + maker and remained incompatible. These versions of the DOM became + known as the "Intermediate DOM". +

+

+ After the standardization of ECMAScript, the W3C DOM Working Group + began drafting a standard DOM specification. The completed + specification, known as "DOM Level 1", became a W3C Recommendation + in late 1998. By 2005, large parts of W3C DOM were well-supported + by common ECMAScript-enabled browsers, including Internet Explorer + 6 (from 2001), Opera, Safari and Gecko-based browsers (like + Mozilla, Firefox, SeaMonkey and Camino). +

+
+
+
+ `; + }, }; export const Performance: Story = { - render: (_, context) => html` - + argTypes: disableStoryControls(metadata), + render: () => { + const controller = createSearchController(); + const paragraphs = generateParagraphs(250); - ${SearchInput(context.globals.variant)} - ${asyncAppend( - generateParagraphs(), - (p) => html`

${p}

` - )}
- `, + return html` + + + ${SearchBar(controller)} + + + ${paragraphs.map((p) => html`

${p}

`)} +
+ `; + }, }; From 91898eeeeb718287ef0de31b5fad06c03c5a8c4a Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 12 Mar 2026 11:27:42 +0200 Subject: [PATCH 3/5] chore: update highlight styles to use new CSS properties --- src/components/highlight/highlight.ts | 8 ++-- src/components/highlight/service.ts | 58 +++++++++++++++------------ stories/highlight.stories.ts | 24 +++++------ stories/navbar.stories.ts | 14 +------ 4 files changed, 49 insertions(+), 55 deletions(-) diff --git a/src/components/highlight/highlight.ts b/src/components/highlight/highlight.ts index 1fc4fb4bd..2c24426b2 100644 --- a/src/components/highlight/highlight.ts +++ b/src/components/highlight/highlight.ts @@ -14,10 +14,10 @@ import { * * @slot - The default slot of the component. * - * @cssproperty --resting-color - The text color for a highlighted text node. - * @cssproperty --resting-background - The background color for a highlighted text node. - * @cssproperty --active-color - The text color for the active highlighted text node. - * @cssproperty --active-background - The background color for the active highlighted text node. + * @cssproperty --foreground - The text color for a highlighted text node. + * @cssproperty --background - The background color for a highlighted text node. + * @cssproperty --foreground-active - The text color for the active highlighted text node. + * @cssproperty --background-active - The background color for the active highlighted text node. */ export default class IgcHighlightComponent extends LitElement { public static readonly tagName = 'igc-highlight'; diff --git a/src/components/highlight/service.ts b/src/components/highlight/service.ts index d2ed12446..1ade19fa0 100644 --- a/src/components/highlight/service.ts +++ b/src/components/highlight/service.ts @@ -3,6 +3,7 @@ import { escapeRegex, first, iterNodes, + nanoid, scrollIntoView, wrap, } from '../common/util.js'; @@ -18,8 +19,6 @@ export type HighlightNavigation = { preventScroll?: boolean; }; -let nextKey = 0; - function* matchText( nodes: IterableIterator, regexp: RegExp @@ -37,21 +36,10 @@ class HighlightService implements ReactiveController { //#region Private properties private readonly _host: IgcHighlightComponent; - - private readonly _key = `igc-highlight-${nextKey++}`; - private readonly _activeKey = `${this._key}-active`; - - private readonly _styles = ` - ::highlight(${this._key}) { - background-color: var(--resting-background, hsla(var(--ig-gray-300), var(--ig-gray-a))); - color: var(--resting-color, white); - } - ::highlight(${this._activeKey}) { - background-color: var(--active-background, hsla(333deg , calc( 78% * 1.23), calc( 49% * 1.34), 1)); - color: var(--active-color, white); - }` as const; - - private readonly _styleSheet!: CSSStyleSheet; + private readonly _id: string; + private readonly _activeId: string; + private readonly _styles: string; + private readonly _styleSheet?: CSSStyleSheet; private _highlight!: Highlight; private _activeHighlight!: Highlight; @@ -75,6 +63,18 @@ class HighlightService implements ReactiveController { constructor(host: IgcHighlightComponent) { this._host = host; this._host.addController(this); + this._id = `igc-highlight-${nanoid()}`; + this._activeId = `${this._id}-active`; + + this._styles = ` + ::highlight(${this._id}) { + background-color: var(--background, var(--ig-secondary-700)); + color: var(--foreground, var(--ig-secondary-700-contrast)); + } + ::highlight(${this._activeId}) { + background-color: var(--background-active, var(--ig-primary-500)); + color: var(--foreground-active, var(--ig-primary-500-contrast)); + }`; if (!isServer) { this._styleSheet = new CSSStyleSheet(); @@ -103,15 +103,20 @@ class HighlightService implements ReactiveController { private _addStylesheet(): void { const root = this._host.renderRoot as ShadowRoot; - root.adoptedStyleSheets.push(this._styleSheet); + + if (this._styleSheet) { + root.adoptedStyleSheets.push(this._styleSheet); + } } private _removeStyleSheet(): void { const root = this._host.renderRoot as ShadowRoot; - root.adoptedStyleSheets = root.adoptedStyleSheets.filter( - (sheet) => sheet !== this._styleSheet - ); + if (this._styleSheet) { + root.adoptedStyleSheets = root.adoptedStyleSheets.filter( + (sheet) => sheet !== this._styleSheet + ); + } } private _createHighlightEntries(): void { @@ -121,18 +126,18 @@ class HighlightService implements ReactiveController { this._activeHighlight = new Highlight(); this._activeHighlight.priority = 1; - CSS.highlights.set(this._key, this._highlight); - CSS.highlights.set(this._activeKey, this._activeHighlight); + CSS.highlights.set(this._id, this._highlight); + CSS.highlights.set(this._activeId, this._activeHighlight); } private _removeHighlightEntries(): void { - CSS.highlights.delete(this._key); - CSS.highlights.delete(this._activeKey); + CSS.highlights.delete(this._id); + CSS.highlights.delete(this._activeId); } private _createRegex(value: string): RegExp { return new RegExp( - `${escapeRegex(value)}`, + escapeRegex(value), this._host.caseSensitive ? 'dg' : 'dgi' ); } @@ -143,6 +148,7 @@ class HighlightService implements ReactiveController { private _updateActiveHighlight(): void { if (this.size) { + this._activeHighlight.clear(); this._activeHighlight.add( this._getRangeByIndex(this._current).cloneRange() ); diff --git a/stories/highlight.stories.ts b/stories/highlight.stories.ts index 59a2b741b..20b3275ef 100644 --- a/stories/highlight.stories.ts +++ b/stories/highlight.stories.ts @@ -179,16 +179,16 @@ export const CustomStyling: Story = { render: () => html` @@ -350,10 +350,10 @@ export const Performance: Story = { return html`