diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 66d728f99..626c7f6cc 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'; @@ -105,6 +106,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 9f3918000..ae869e384 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -368,7 +368,7 @@ export function roundByDPR(value: number): number { } export function scrollIntoView( - element?: HTMLElement, + element?: HTMLElement | null, config?: ScrollIntoViewOptions ): void { if (!element) { @@ -505,6 +505,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/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..f85474861 --- /dev/null +++ b/src/components/highlight/highlight.ts @@ -0,0 +1,212 @@ +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { addThemingController } from '../../theming/theming-controller.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { + createHighlightController, + type HighlightNavigation, +} from './service.js'; +import { styles as shared } from './themes/shared/highlight.common.css.js'; +import { all } from './themes/themes.js'; + +/** + * The highlight component provides efficient searching and highlighting of text + * projected into it via its default slot. It uses the native CSS Custom Highlight API + * to apply highlight styles to matched text nodes without modifying the DOM. + * + * The component supports case-sensitive matching, programmatic navigation between + * matches, and automatic scroll-into-view of the active match. + * + * @element igc-highlight + * + * @slot - The default slot. Place the text content you want to search and highlight here. + * + * @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. + * + * @example + * Basic usage — wrap your text and set the `search-text` attribute: + * ```html + * + *

Hello, world! The world is a wonderful place.

+ *
+ * ``` + * + * @example + * Case-sensitive search: + * ```html + * + *

Hello hello HELLO — only the first one matches.

+ *
+ * ``` + * + * @example + * Navigating between matches programmatically: + * ```typescript + * const highlight = document.querySelector('igc-highlight'); + * + * highlight.searchText = 'world'; + * console.log(highlight.size); // total number of matches + * console.log(highlight.current); // index of the active match (0-based) + * + * highlight.next(); // move to the next match + * highlight.previous(); // move to the previous match + * highlight.setActive(2); // jump to a specific match by index + * ``` + * + * @example + * Prevent scroll-into-view when navigating: + * ```typescript + * const highlight = document.querySelector('igc-highlight'); + * highlight.next({ preventScroll: true }); + * ``` + * + * @example + * Re-run search after dynamic content changes (e.g. lazy-loaded text): + * ```typescript + * const highlight = document.querySelector('igc-highlight'); + * // After slotted content has been updated: + * highlight.search(); + * ``` + */ +export default class IgcHighlightComponent extends LitElement { + public static readonly tagName = 'igc-highlight'; + public static override styles = [shared]; + + /* blazorSuppress */ + public static register(): void { + registerComponent(IgcHighlightComponent); + } + + //#region Internal properties and state + + private readonly _service = createHighlightController(this); + + private _caseSensitive = false; + private _searchText = ''; + + //#endregion + + //#region Public properties and attributes + + /** + * Whether to match the searched text with case sensitivity in mind. + * When `true`, only exact-case occurrences of `searchText` are highlighted. + * + * @attr case-sensitive + * @default false + */ + @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. + * Setting this property triggers a new search automatically. + * An empty string clears all highlights. + * + * @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 total number of matches found for the current `searchText`. Returns `0` when there are no matches or `searchText` is empty. */ + public get size(): number { + return this._service.size; + } + + /** The zero-based index of the currently active (focused) match. Returns `0` when there are no matches. */ + public get current(): number { + return this._service.current; + } + + //#endregion + + constructor() { + super(); + + addThemingController(this, all, { + themeChange: this._addStylesheet, + }); + } + + //#region Internal methods + + private _addStylesheet(): void { + this._service.attachStylesheet(); + } + + //#endregion + + //#region Public methods + + /** + * Moves the active highlight to the next match. + * Wraps around to the first match after the last one. + * + * @param options - Optional navigation options (e.g. `preventScroll`). + */ + public next(options?: HighlightNavigation): void { + this._service.next(options); + } + + /** + * Moves the active highlight to the previous match. + * Wraps around to the last match when going back from the first one. + * + * @param options - Optional navigation options (e.g. `preventScroll`). + */ + public previous(options?: HighlightNavigation): void { + this._service.previous(options); + } + + /** + * Moves the active highlight to the match at the specified zero-based index. + * + * @param index - The zero-based index of the match to activate. + * @param options - Optional navigation options (e.g. `preventScroll`). + */ + public setActive(index: number, options?: HighlightNavigation): void { + this._service.setActive(index, options); + } + + /** + * Re-runs the highlight search based on the current `searchText` and `caseSensitive` values. + * + * Call this method after the slotted content changes dynamically (e.g. after lazy loading + * or programmatic DOM mutations) to ensure all matches are up to date. + */ + public search(): void { + if (this.hasUpdated) { + this._service.clear(); + this._service.find(this.searchText); + } + } + + //#endregion + + 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..c9204daec --- /dev/null +++ b/src/components/highlight/service.ts @@ -0,0 +1,245 @@ +import { isServer, type ReactiveController } from 'lit'; +import { + escapeRegex, + first, + iterNodes, + nanoid, + 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; +}; + +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 _id: string; + private readonly _activeId: string; + private readonly _styles: string; + private readonly _styleSheet?: CSSStyleSheet; + + private _highlight!: Highlight; + private _activeHighlight!: Highlight; + + private _current = 0; + + //#endregion + + //#region Public properties + + /** + * The total number of matches found in the component's content. + */ + public get size(): number { + return this._highlight.size; + } + + /** + * The index of the currently active match. Returns 0 if there are no matches. + */ + public get current(): number { + return this._current; + } + + //#endregion + + 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(); + this._styleSheet.replaceSync(this._styles); + } + } + + //#region Controller life-cycle + + /** @internal */ + public hostConnected(): void { + this._createHighlightEntries(); + this.find(this._host.searchText); + } + + /** @internal */ + public hostDisconnected(): void { + this._removeHighlightEntries(); + this._removeStyleSheet(); + } + + //#endregion + + //#region Private methods + + private _removeStyleSheet(): void { + const root = this._host.renderRoot as ShadowRoot; + + if (this._styleSheet) { + 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._id, this._highlight); + CSS.highlights.set(this._activeId, this._activeHighlight); + } + + private _removeHighlightEntries(): void { + CSS.highlights.delete(this._id); + CSS.highlights.delete(this._activeId); + } + + 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.clear(); + 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 + + /** + * Attaches the service's stylesheet to the render root. + * Necessary for the component to apply highlight styles to its content. + */ + public attachStylesheet(): void { + const adoptedSheets = (this._host.renderRoot as ShadowRoot) + .adoptedStyleSheets; + + if (this._styleSheet && !adoptedSheets.includes(this._styleSheet)) { + adoptedSheets.push(this._styleSheet); + } + } + + /** + * Finds matches for the given search text in the component's content and creates highlight ranges for them. + */ + 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/components/highlight/themes/dark/_themes.scss b/src/components/highlight/themes/dark/_themes.scss new file mode 100644 index 000000000..d70a07920 --- /dev/null +++ b/src/components/highlight/themes/dark/_themes.scss @@ -0,0 +1,9 @@ +@use 'sass:map'; +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/dark/highlight' as *; + +$base: digest-schema($dark-base-highlight); +$material: digest-schema($dark-material-highlight); +$bootstrap: digest-schema($dark-bootstrap-highlight); +$fluent: digest-schema($dark-fluent-highlight); +$indigo: digest-schema($dark-indigo-highlight); diff --git a/src/components/highlight/themes/dark/highlight.bootstrap.scss b/src/components/highlight/themes/dark/highlight.bootstrap.scss new file mode 100644 index 000000000..68cc3c7bb --- /dev/null +++ b/src/components/highlight/themes/dark/highlight.bootstrap.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $bootstrap; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme)); +} diff --git a/src/components/highlight/themes/dark/highlight.fluent.scss b/src/components/highlight/themes/dark/highlight.fluent.scss new file mode 100644 index 000000000..679ee2e59 --- /dev/null +++ b/src/components/highlight/themes/dark/highlight.fluent.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $fluent; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme)); +} diff --git a/src/components/highlight/themes/dark/highlight.indigo.scss b/src/components/highlight/themes/dark/highlight.indigo.scss new file mode 100644 index 000000000..5c1ebeb56 --- /dev/null +++ b/src/components/highlight/themes/dark/highlight.indigo.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $indigo; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme)); +} diff --git a/src/components/highlight/themes/dark/highlight.material.scss b/src/components/highlight/themes/dark/highlight.material.scss new file mode 100644 index 000000000..0dddc23d0 --- /dev/null +++ b/src/components/highlight/themes/dark/highlight.material.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $material; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme)); +} diff --git a/src/components/highlight/themes/dark/highlight.shared.scss b/src/components/highlight/themes/dark/highlight.shared.scss new file mode 100644 index 000000000..96b6530e6 --- /dev/null +++ b/src/components/highlight/themes/dark/highlight.shared.scss @@ -0,0 +1,6 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +:host { + @include css-vars-from-theme($base); +} diff --git a/src/components/highlight/themes/light/_themes.scss b/src/components/highlight/themes/light/_themes.scss new file mode 100644 index 000000000..479da3adc --- /dev/null +++ b/src/components/highlight/themes/light/_themes.scss @@ -0,0 +1,9 @@ +@use 'sass:map'; +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/light/highlight' as *; + +$base: digest-schema($light-highlight); +$material: digest-schema($material-highlight); +$bootstrap: digest-schema($bootstrap-highlight); +$fluent: digest-schema($fluent-highlight); +$indigo: digest-schema($indigo-highlight); \ No newline at end of file diff --git a/src/components/highlight/themes/light/highlight.bootstrap.scss b/src/components/highlight/themes/light/highlight.bootstrap.scss new file mode 100644 index 000000000..8bade7490 --- /dev/null +++ b/src/components/highlight/themes/light/highlight.bootstrap.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $bootstrap; + +:host { + @include css-vars-from-theme(diff($base, $theme)); +} diff --git a/src/components/highlight/themes/light/highlight.fluent.scss b/src/components/highlight/themes/light/highlight.fluent.scss new file mode 100644 index 000000000..4d87074b4 --- /dev/null +++ b/src/components/highlight/themes/light/highlight.fluent.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $fluent; + +:host { + @include css-vars-from-theme(diff($base, $theme)); +} diff --git a/src/components/highlight/themes/light/highlight.indigo.scss b/src/components/highlight/themes/light/highlight.indigo.scss new file mode 100644 index 000000000..641839724 --- /dev/null +++ b/src/components/highlight/themes/light/highlight.indigo.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $indigo; + +:host { + @include css-vars-from-theme(diff($base, $theme)); +} diff --git a/src/components/highlight/themes/light/highlight.material.scss b/src/components/highlight/themes/light/highlight.material.scss new file mode 100644 index 000000000..3271cfef8 --- /dev/null +++ b/src/components/highlight/themes/light/highlight.material.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $material; + +:host { + @include css-vars-from-theme(diff($base, $theme)); +} diff --git a/src/components/highlight/themes/light/highlight.shared.scss b/src/components/highlight/themes/light/highlight.shared.scss new file mode 100644 index 000000000..96b6530e6 --- /dev/null +++ b/src/components/highlight/themes/light/highlight.shared.scss @@ -0,0 +1,6 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +:host { + @include css-vars-from-theme($base); +} diff --git a/src/components/highlight/themes/shared/highlight.common.scss b/src/components/highlight/themes/shared/highlight.common.scss new file mode 100644 index 000000000..457d50bc8 --- /dev/null +++ b/src/components/highlight/themes/shared/highlight.common.scss @@ -0,0 +1,13 @@ +@use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $material; + +:host { + --background: #{var-get($theme, 'resting-background')}; + --foreground: #{var-get($theme, 'resting-color')}; + --background-active: #{var-get($theme, 'active-background')}; + --foreground-active: #{var-get($theme, 'active-color')}; + + display: contents; +} diff --git a/src/components/highlight/themes/themes.ts b/src/components/highlight/themes/themes.ts new file mode 100644 index 000000000..00c4abc1e --- /dev/null +++ b/src/components/highlight/themes/themes.ts @@ -0,0 +1,54 @@ +import { css } from 'lit'; + +import type { Themes } from '../../../theming/types.js'; + +// Dark Overrides +import { styles as bootstrapDark } from './dark/highlight.bootstrap.css.js'; +import { styles as fluentDark } from './dark/highlight.fluent.css.js'; +import { styles as indigoDark } from './dark/highlight.indigo.css.js'; +import { styles as materialDark } from './dark/highlight.material.css.js'; +import { styles as sharedDark } from './dark/highlight.shared.css.js'; +// Light Overrides +import { styles as bootstrapLight } from './light/highlight.bootstrap.css.js'; +import { styles as fluentLight } from './light/highlight.fluent.css.js'; +import { styles as indigoLight } from './light/highlight.indigo.css.js'; +import { styles as materialLight } from './light/highlight.material.css.js'; +import { styles as sharedLight } from './light/highlight.shared.css.js'; + +const light = { + shared: css` + ${sharedLight} + `, + bootstrap: css` + ${bootstrapLight} + `, + material: css` + ${materialLight} + `, + indigo: css` + ${indigoLight} + `, + fluent: css` + ${fluentLight} + `, +}; + +const dark = { + shared: css` + ${sharedDark} + `, + bootstrap: css` + ${bootstrapDark} + `, + material: css` + ${materialDark} + `, + indigo: css` + ${indigoDark} + `, + fluent: css` + ${fluentDark} + `, +}; + +export const all: Themes = { light, dark }; diff --git a/src/index.ts b/src/index.ts index d0047004b..d62508469 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,6 +69,7 @@ export { default as IgcTreeItemComponent } from './components/tree/tree-item.js' export { default as IgcSplitterComponent } from './components/splitter/splitter.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'; export { default as IgcThemeProviderComponent } from './components/theme-provider/theme-provider.js'; diff --git a/stories/highlight.stories.ts b/stories/highlight.stories.ts new file mode 100644 index 000000000..20b3275ef --- /dev/null +++ b/stories/highlight.stories.ts @@ -0,0 +1,373 @@ +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 { + IgcDividerComponent, + IgcExpansionPanelComponent, + IgcHighlightComponent, + IgcIconButtonComponent, + IgcInputComponent, + defineComponents, +} from 'igniteui-webcomponents'; +import { disableStoryControls } from './story.js'; + +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 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', + 'dolor', + 'sit', + 'amet', + 'consectetur', + 'adipiscing', + 'elit', + 'sed', + 'do', + 'eiusmod', + 'tempor', + 'incididunt', + 'ut', + 'labore', + 'et', + 'dolore', + 'magna', + 'aliqua', + ]; + + 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(' '); + }); +} + +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 + 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) => { + const controller = createSearchController(); + + return html` + + + ${SearchBar(controller)} + + +

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 = { + argTypes: disableStoryControls(metadata), + render: () => { + const controller = createSearchController(); + const paragraphs = generateParagraphs(250); + + return html` + + + ${SearchBar(controller)} + + + ${paragraphs.map((p) => html`

${p}

`)} +
+ `; + }, +};