mirror of
https://github.com/home-assistant/frontend.git
synced 2026-04-04 01:44:02 +00:00
Compare commits
40 Commits
fix-form-i
...
quick-sear
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10033dd904 | ||
|
|
8427a1ae96 | ||
|
|
ebd82d21d3 | ||
|
|
e3f842a5c6 | ||
|
|
e6fbf9360e | ||
|
|
e792208d72 | ||
|
|
deb4a9ecd9 | ||
|
|
e1db282c7d | ||
|
|
1394be628f | ||
|
|
41ab292f08 | ||
|
|
e9dcc27e98 | ||
|
|
6940d4519f | ||
|
|
1860f1f32e | ||
|
|
0da8a5b42b | ||
|
|
84f4252b17 | ||
|
|
e4ed238113 | ||
|
|
0ce3690d98 | ||
|
|
d517a4bf3c | ||
|
|
d7b453627a | ||
|
|
9471b4594f | ||
|
|
cec3380aef | ||
|
|
4476086a7f | ||
|
|
bd35ca25b0 | ||
|
|
0497da3915 | ||
|
|
3ccb98ffe0 | ||
|
|
a56aabed1e | ||
|
|
65d793135c | ||
|
|
4dd53f0a96 | ||
|
|
95bdca0ce2 | ||
|
|
e4bd5c611d | ||
|
|
4b92648cd0 | ||
|
|
a656cd1114 | ||
|
|
2b72cd9ca1 | ||
|
|
ed2790fa0d | ||
|
|
7949e2798f | ||
|
|
0245edfaf3 | ||
|
|
076ecbb08e | ||
|
|
b456b6630c | ||
|
|
e700b58cc3 | ||
|
|
9c0896fff9 |
507
src/common/string/search-highlight.ts
Normal file
507
src/common/string/search-highlight.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, unsafeCSS } from "lit";
|
||||
import { normalizeSearchText, splitSearchTerms } from "./search-query";
|
||||
|
||||
export interface HighlightRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface NormalizedIndexMap {
|
||||
normalizedText: string;
|
||||
normalizedIndexMap: HighlightRange[];
|
||||
}
|
||||
|
||||
export type HighlightedText =
|
||||
| string
|
||||
| TemplateResult
|
||||
| (string | TemplateResult)[]
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const HIGHLIGHT_NAME_PREFIX = "ha-search";
|
||||
// Shared selector so range extraction and mutation checks stay in sync.
|
||||
const HIGHLIGHT_MARK_SELECTOR = "mark.ha-highlight";
|
||||
|
||||
const tokenizeSearchQuery = (query: string): string[] => [
|
||||
...new Set(splitSearchTerms(query)),
|
||||
];
|
||||
|
||||
/**
|
||||
* Build normalized text and an index map back to original indexes.
|
||||
* Needed because normalization can change character length/index positions.
|
||||
*/
|
||||
const buildNormalizedIndexMap = (
|
||||
text: string,
|
||||
language?: string
|
||||
): NormalizedIndexMap => {
|
||||
let normalizedText = "";
|
||||
const normalizedIndexMap: HighlightRange[] = [];
|
||||
|
||||
let originalIndex = 0;
|
||||
|
||||
for (const char of text) {
|
||||
const start = originalIndex;
|
||||
const end = start + char.length;
|
||||
const normalizedChar = normalizeSearchText(char, language);
|
||||
|
||||
normalizedText += normalizedChar;
|
||||
|
||||
// One original character can normalize into multiple UTF-16 code units.
|
||||
// Keep a mapping entry for each normalized code unit because String#indexOf
|
||||
// and String#length operate on UTF-16 indexes.
|
||||
for (const _codeUnit of normalizedChar.split("")) {
|
||||
normalizedIndexMap.push({ start, end });
|
||||
}
|
||||
|
||||
originalIndex = end;
|
||||
}
|
||||
|
||||
return { normalizedText, normalizedIndexMap };
|
||||
};
|
||||
|
||||
const mergeHighlightRanges = (ranges: HighlightRange[]): HighlightRange[] => {
|
||||
if (!ranges.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
|
||||
const mergedRanges: HighlightRange[] = [{ ...sortedRanges[0] }];
|
||||
|
||||
// Merge overlapping/adjacent ranges so the rendered marks stay minimal.
|
||||
for (let i = 1; i < sortedRanges.length; i++) {
|
||||
const previousRange = mergedRanges[mergedRanges.length - 1];
|
||||
const currentRange = sortedRanges[i];
|
||||
|
||||
if (currentRange.start <= previousRange.end) {
|
||||
previousRange.end = Math.max(previousRange.end, currentRange.end);
|
||||
continue;
|
||||
}
|
||||
|
||||
mergedRanges.push({ ...currentRange });
|
||||
}
|
||||
|
||||
return mergedRanges;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert rendered `<mark>` nodes into DOM Ranges for `CSS.highlights`.
|
||||
* We walk text nodes because Lit templates can place comment markers before
|
||||
* text inside `<mark>`, so `firstChild` is not reliably the text node.
|
||||
*/
|
||||
const getHighlightRangesFromMarks = (root: ShadowRoot): Range[] => {
|
||||
const ranges: Range[] = [];
|
||||
|
||||
root.querySelectorAll(HIGHLIGHT_MARK_SELECTOR).forEach((mark) => {
|
||||
const textWalker = document.createTreeWalker(mark, NodeFilter.SHOW_TEXT);
|
||||
let textNode = textWalker.nextNode();
|
||||
|
||||
while (textNode) {
|
||||
const text = textNode.textContent;
|
||||
if (text) {
|
||||
const range = new Range();
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, text.length);
|
||||
ranges.push(range);
|
||||
}
|
||||
|
||||
textNode = textWalker.nextNode();
|
||||
}
|
||||
});
|
||||
|
||||
return ranges;
|
||||
};
|
||||
|
||||
const createHighlightStyle = (highlightName: string): string => css`
|
||||
.ha-highlight {
|
||||
/* Visual highlight comes from ::highlight(...), not the <mark> itself. */
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
::highlight(${unsafeCSS(highlightName)}) {
|
||||
background-color: var(
|
||||
--ha-highlight-bg,
|
||||
var(--ha-color-fill-primary-normal-hover)
|
||||
);
|
||||
color: var(--ha-highlight-color, var(--primary-text-color));
|
||||
}
|
||||
`.cssText;
|
||||
|
||||
const renderHighlightedParts = (
|
||||
text: string,
|
||||
ranges: HighlightRange[]
|
||||
): (string | TemplateResult)[] => {
|
||||
const parts: (string | TemplateResult)[] = [];
|
||||
let previousIndex = 0;
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range.start > previousIndex) {
|
||||
parts.push(text.slice(previousIndex, range.start));
|
||||
}
|
||||
|
||||
parts.push(
|
||||
html`<mark class="ha-highlight"
|
||||
>${text.slice(range.start, range.end)}</mark
|
||||
>`
|
||||
);
|
||||
|
||||
previousIndex = range.end;
|
||||
}
|
||||
|
||||
if (previousIndex < text.length) {
|
||||
parts.push(text.slice(previousIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search highlighting helper with two integration paths:
|
||||
* 1) call `renderHighlightedText` + `applyFromMarks` when updates are driven
|
||||
* by known state changes (like filter changes),
|
||||
* 2) call `startAutoSyncFromMarks` when highlighted DOM can change
|
||||
* independently of filter changes (like virtualized rows).
|
||||
*/
|
||||
export class SearchHighlight {
|
||||
// `CSS.highlights` is document-global, not per shadow root.
|
||||
// Each instance needs a unique key so that components do not overwrite each
|
||||
// other's highlight ranges.
|
||||
private static _nextHighlightId = 0;
|
||||
|
||||
// Fingerprints include Node identity, so map nodes to stable numeric IDs.
|
||||
private static _nodeIds = new WeakMap<Node, number>();
|
||||
|
||||
private static _nextNodeId = 0;
|
||||
|
||||
// Cache the last apply inputs to avoid re-registering identical highlights.
|
||||
private _lastCacheKey?: string;
|
||||
|
||||
private _lastFingerprint?: string;
|
||||
|
||||
private readonly _highlightName?: string;
|
||||
|
||||
private _autoSyncObserver?: MutationObserver;
|
||||
|
||||
private _autoSyncQueued = false;
|
||||
|
||||
private _autoSyncCacheKeyProvider?: () => string | null | undefined;
|
||||
|
||||
private _autoSyncObservedTarget?: Node;
|
||||
|
||||
public constructor(private readonly _root?: ShadowRoot) {
|
||||
if (this._root) {
|
||||
this._highlightName = `${HIGHLIGHT_NAME_PREFIX}-${SearchHighlight._nextHighlightId++}`;
|
||||
this._addHighlightStyle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return text ranges that should be highlighted for the given query.
|
||||
* Useful when the caller needs ranges without rendering `<mark>` output.
|
||||
*/
|
||||
public getHighlightRanges(
|
||||
text: string,
|
||||
query: string,
|
||||
language?: string
|
||||
): HighlightRange[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const terms = tokenizeSearchQuery(query);
|
||||
if (!terms.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { normalizedText, normalizedIndexMap } = buildNormalizedIndexMap(
|
||||
text,
|
||||
language
|
||||
);
|
||||
|
||||
// Text can normalize to empty (for example, combining marks only).
|
||||
if (!normalizedText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ranges: HighlightRange[] = [];
|
||||
|
||||
for (const term of terms) {
|
||||
const normalizedTerm = normalizeSearchText(term, language);
|
||||
// Some tokens normalize to empty (like combining marks); skip them.
|
||||
if (!normalizedTerm) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let matchIndex = normalizedText.indexOf(normalizedTerm);
|
||||
|
||||
while (matchIndex !== -1) {
|
||||
// Convert normalized-text match indexes back to original-text indexes.
|
||||
// `indexOf` guarantees the full normalized term is within bounds, and
|
||||
// we append one mapping item per normalized UTF-16 code unit.
|
||||
const start = normalizedIndexMap[matchIndex]!.start;
|
||||
const end =
|
||||
normalizedIndexMap[matchIndex + normalizedTerm.length - 1]!.end;
|
||||
ranges.push({ start, end });
|
||||
|
||||
matchIndex = normalizedText.indexOf(
|
||||
normalizedTerm,
|
||||
matchIndex + normalizedTerm.length
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return mergeHighlightRanges(ranges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render plain text with matching segments wrapped in `<mark>`.
|
||||
* `<mark>` nodes are used as stable anchors for range extraction.
|
||||
*/
|
||||
public renderHighlightedText(
|
||||
text: string | null | undefined,
|
||||
query: string | null | undefined,
|
||||
language?: string
|
||||
): HighlightedText {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const ranges = this.getHighlightRanges(text, query ?? "", language);
|
||||
if (!ranges.length) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return renderHighlightedParts(text, ranges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read rendered `<mark>` nodes from the root and apply matching
|
||||
* `CSS.highlights` ranges.
|
||||
* `cacheKey` should represent the current query/filter used to build marks.
|
||||
*/
|
||||
public applyFromMarks(cacheKey?: string): void {
|
||||
if (!this._root) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyFromRanges(getHighlightRangesFromMarks(this._root), cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply precomputed ranges directly to `CSS.highlights`.
|
||||
* Use this when ranges are built outside this class.
|
||||
* `cacheKey` should represent the inputs used to build `ranges`.
|
||||
*/
|
||||
public applyFromRanges(ranges: Range[], cacheKey?: string): void {
|
||||
if (!this._root || !this._highlightName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const highlightRegistry = globalThis.CSS?.highlights;
|
||||
if (!highlightRegistry || typeof Highlight === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ranges.length) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const fingerprint = this._getRangesFingerprint(ranges);
|
||||
// Skip writes only when both the caller key and concrete range positions
|
||||
// are unchanged.
|
||||
if (
|
||||
cacheKey === this._lastCacheKey &&
|
||||
fingerprint === this._lastFingerprint
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastCacheKey = cacheKey;
|
||||
this._lastFingerprint = fingerprint;
|
||||
|
||||
highlightRegistry.set(this._highlightName, new Highlight(...ranges));
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-sync `CSS.highlights` from `<mark>` nodes whenever marked DOM changes.
|
||||
* Use this for components where highlighted DOM can change without filter
|
||||
* changes (for example, virtualized lists).
|
||||
* `cacheKeyProvider` should return the current query/filter string.
|
||||
* `observedTarget` allows callers to scope observation to a subtree.
|
||||
*/
|
||||
public startAutoSyncFromMarks(
|
||||
cacheKeyProvider: () => string | null | undefined,
|
||||
observedTarget?: Node
|
||||
): void {
|
||||
if (!this._root || !this._highlightName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._autoSyncCacheKeyProvider = cacheKeyProvider;
|
||||
this._autoSyncObservedTarget = observedTarget ?? this._root;
|
||||
|
||||
if (!this._autoSyncObserver) {
|
||||
this._autoSyncObserver = new MutationObserver((records) => {
|
||||
if (
|
||||
!records.some((record) => this._mutationAffectsHighlights(record))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._queueAutoSyncFromMarks();
|
||||
});
|
||||
}
|
||||
|
||||
this._autoSyncObserver.disconnect();
|
||||
this._autoSyncObserver.observe(this._autoSyncObservedTarget, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
this._queueAutoSyncFromMarks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-sync started via `startAutoSyncFromMarks`.
|
||||
*/
|
||||
public stopAutoSyncFromMarks(): void {
|
||||
this._autoSyncObserver?.disconnect();
|
||||
this._autoSyncQueued = false;
|
||||
this._autoSyncCacheKeyProvider = undefined;
|
||||
this._autoSyncObservedTarget = undefined;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
if (!this._root) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.CSS?.highlights?.delete(this._highlightName!);
|
||||
this._lastCacheKey = undefined;
|
||||
this._lastFingerprint = undefined;
|
||||
}
|
||||
|
||||
private _getNodeId(node: Node): number {
|
||||
let nodeId = SearchHighlight._nodeIds.get(node);
|
||||
if (nodeId !== undefined) {
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
nodeId = SearchHighlight._nextNodeId++;
|
||||
SearchHighlight._nodeIds.set(node, nodeId);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable signature for a set of ranges so we can detect real range
|
||||
* changes even when the count stays the same.
|
||||
*/
|
||||
private _getRangesFingerprint(ranges: Range[]): string {
|
||||
return ranges
|
||||
.map((range) => {
|
||||
const startNodeId = this._getNodeId(range.startContainer);
|
||||
const endNodeId = this._getNodeId(range.endContainer);
|
||||
return `${startNodeId}:${range.startOffset}-${endNodeId}:${range.endOffset}`;
|
||||
})
|
||||
.join("|");
|
||||
}
|
||||
|
||||
private _queueAutoSyncFromMarks(): void {
|
||||
if (this._autoSyncQueued) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._autoSyncQueued = true;
|
||||
// Coalesce bursts of mutations into a single highlight recomputation.
|
||||
queueMicrotask(() => {
|
||||
this._autoSyncQueued = false;
|
||||
if (!this._root || !(this._root.host as HTMLElement).isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = this._autoSyncCacheKeyProvider?.()?.trim();
|
||||
if (!cacheKey) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyFromMarks(cacheKey);
|
||||
});
|
||||
}
|
||||
|
||||
private _mutationAffectsHighlights(mutation: MutationRecord): boolean {
|
||||
if (mutation.type === "characterData") {
|
||||
return this._nodeContainsHighlightMark(mutation.target);
|
||||
}
|
||||
|
||||
if (mutation.type !== "childList") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._nodeContainsHighlightMark(mutation.target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (this._nodeContainsHighlightMark(node)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (this._nodeContainsHighlightMark(node)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a node is a highlight mark, contains one, or is a text/comment
|
||||
* node inside one. The text/comment case covers Lit marker nodes.
|
||||
*/
|
||||
private _nodeContainsHighlightMark(node: Node): boolean {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
return (
|
||||
element.matches(HIGHLIGHT_MARK_SELECTOR) ||
|
||||
Boolean(element.querySelector(HIGHLIGHT_MARK_SELECTOR))
|
||||
);
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
|
||||
return Boolean(
|
||||
(node as DocumentFragment).querySelector?.(HIGHLIGHT_MARK_SELECTOR)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
node.nodeType === Node.TEXT_NODE ||
|
||||
node.nodeType === Node.COMMENT_NODE
|
||||
) {
|
||||
const parentElement = (node as ChildNode).parentElement;
|
||||
return Boolean(parentElement?.closest(HIGHLIGHT_MARK_SELECTOR));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject marker styles and `::highlight()` theme colors.
|
||||
*/
|
||||
private _addHighlightStyle(): void {
|
||||
if (!this._root || !this._highlightName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = createHighlightStyle(this._highlightName);
|
||||
this._root.appendChild(style);
|
||||
}
|
||||
}
|
||||
13
src/common/string/search-query.ts
Normal file
13
src/common/string/search-query.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { stripDiacritics } from "./strip-diacritics";
|
||||
|
||||
/**
|
||||
* Normalize text for search comparisons (case-insensitive + diacritics-insensitive).
|
||||
*/
|
||||
export const normalizeSearchText = (text: string, language?: string): string =>
|
||||
stripDiacritics(text).toLocaleLowerCase(language);
|
||||
|
||||
/**
|
||||
* Split a user query into whitespace-delimited search terms.
|
||||
*/
|
||||
export const splitSearchTerms = (query: string): string[] =>
|
||||
query.trim().split(/\s+/).filter(Boolean);
|
||||
@@ -17,6 +17,7 @@ import { STRINGS_SEPARATOR_DOT } from "../../common/const";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
import { SearchHighlight } from "../../common/string/search-highlight";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { groupBy } from "../../common/util/group-by";
|
||||
@@ -178,6 +179,8 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
private _lastUpdate = 0;
|
||||
|
||||
private _searchHighlight?: SearchHighlight;
|
||||
|
||||
// @ts-ignore
|
||||
@restoreScroll(".scroller") private _savedScrollPos?: number;
|
||||
|
||||
@@ -234,21 +237,36 @@ export class HaDataTable extends LitElement {
|
||||
// Force update of location of rows
|
||||
this._filteredData = [...this._filteredData];
|
||||
}
|
||||
|
||||
// Re-attach observer when the element reconnects.
|
||||
if (this.hasUpdated) {
|
||||
this._updateSearchHighlightSync();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._searchHighlight?.stopAutoSyncFromMarks();
|
||||
this._searchHighlight?.clear();
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.updateComplete.then(() => this._calcTableHeight());
|
||||
this._updateSearchHighlightSync();
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
const header = this.renderRoot.querySelector(".mdc-data-table__header-row");
|
||||
if (!header) {
|
||||
return;
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
if (changedProperties.has("_filter")) {
|
||||
this._updateSearchHighlightSync();
|
||||
}
|
||||
if (header.scrollWidth > header.clientWidth) {
|
||||
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
|
||||
} else {
|
||||
this.style.removeProperty("--table-row-width");
|
||||
|
||||
const header = this.renderRoot.querySelector(".mdc-data-table__header-row");
|
||||
if (header) {
|
||||
if (header.scrollWidth > header.clientWidth) {
|
||||
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
|
||||
} else {
|
||||
this.style.removeProperty("--table-row-width");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,7 +638,12 @@ export class HaDataTable extends LitElement {
|
||||
${column.template
|
||||
? column.template(row)
|
||||
: narrow && column.main
|
||||
? html`<div class="primary">${row[key]}</div>
|
||||
? html`<div class="primary">
|
||||
${this._renderValueWithHighlight(
|
||||
row[key],
|
||||
column.filterable
|
||||
)}
|
||||
</div>
|
||||
<div class="secondary">
|
||||
${Object.entries(columns)
|
||||
.filter(
|
||||
@@ -638,15 +661,21 @@ export class HaDataTable extends LitElement {
|
||||
([key2, column2], i) =>
|
||||
html`${i !== 0
|
||||
? STRINGS_SEPARATOR_DOT
|
||||
: nothing}${column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]}`
|
||||
: nothing}${this._renderCellValue(
|
||||
column2,
|
||||
key2,
|
||||
row
|
||||
)}`
|
||||
)}
|
||||
</div>
|
||||
${column.extraTemplate
|
||||
? column.extraTemplate(row)
|
||||
: nothing}`
|
||||
: html`${row[key]}${column.extraTemplate
|
||||
: html`${this._renderCellValue(
|
||||
column,
|
||||
key,
|
||||
row
|
||||
)}${column.extraTemplate
|
||||
? column.extraTemplate(row)
|
||||
: nothing}`}
|
||||
</div>
|
||||
@@ -656,6 +685,69 @@ export class HaDataTable extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _renderCellValue(
|
||||
column: DataTableColumnData,
|
||||
key: string,
|
||||
row: DataTableRowData
|
||||
) {
|
||||
if (column.template) {
|
||||
return column.template(row);
|
||||
}
|
||||
|
||||
return this._renderValueWithHighlight(row[key], column.filterable);
|
||||
}
|
||||
|
||||
private _renderValueWithHighlight(
|
||||
value: unknown,
|
||||
filterable?: boolean
|
||||
): unknown {
|
||||
if (!filterable) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const filter = this._filter.trim();
|
||||
if (!filter) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== "string" && typeof value !== "number") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const text = String(value);
|
||||
return this._getSearchHighlight().renderHighlightedText(
|
||||
text,
|
||||
filter,
|
||||
this.hass.locale.language
|
||||
);
|
||||
}
|
||||
|
||||
private _getSearchHighlight(): SearchHighlight {
|
||||
if (!this._searchHighlight) {
|
||||
this._searchHighlight = new SearchHighlight(
|
||||
this.renderRoot as ShadowRoot
|
||||
);
|
||||
}
|
||||
return this._searchHighlight;
|
||||
}
|
||||
|
||||
private _updateSearchHighlightSync(): void {
|
||||
if (!this._filter.trim()) {
|
||||
this._searchHighlight?.stopAutoSyncFromMarks();
|
||||
this._searchHighlight?.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const observedTarget =
|
||||
this.renderRoot.querySelector("lit-virtualizer") ||
|
||||
(this.renderRoot as ShadowRoot);
|
||||
|
||||
this._getSearchHighlight().startAutoSyncFromMarks(
|
||||
() => this._filter,
|
||||
observedTarget
|
||||
);
|
||||
}
|
||||
|
||||
private async _sortFilterData() {
|
||||
const startTime = new Date().getTime();
|
||||
const timeBetweenUpdate = startTime - this._lastUpdate;
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { FuseOptionKey, IFuseOptions } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ipCompare, stringCompare } from "../../common/string/compare";
|
||||
import { stripDiacritics } from "../../common/string/strip-diacritics";
|
||||
import {
|
||||
normalizeSearchText,
|
||||
splitSearchTerms,
|
||||
} from "../../common/string/search-query";
|
||||
import type {
|
||||
ClonedDataTableColumnData,
|
||||
DataTableRowData,
|
||||
@@ -47,10 +50,10 @@ const getSearchableValue = (
|
||||
const stringValues = value
|
||||
.filter((item) => item != null && typeof item !== "object")
|
||||
.map(String);
|
||||
return stripDiacritics(stringValues.join(" ").toLowerCase());
|
||||
return normalizeSearchText(stringValues.join(" "));
|
||||
}
|
||||
|
||||
return stripDiacritics(String(value).toLowerCase());
|
||||
return normalizeSearchText(String(value));
|
||||
};
|
||||
|
||||
/** Filters data using exact substring matching (all terms must match). */
|
||||
@@ -141,7 +144,7 @@ const filterData = (
|
||||
columns: SortableColumnContainer,
|
||||
filter: string
|
||||
): DataTableRowData[] => {
|
||||
const normalizedFilter = stripDiacritics(filter.toLowerCase().trim());
|
||||
const normalizedFilter = normalizeSearchText(filter).trim();
|
||||
|
||||
if (!normalizedFilter) {
|
||||
return data;
|
||||
@@ -153,7 +156,7 @@ const filterData = (
|
||||
return data;
|
||||
}
|
||||
|
||||
const terms = normalizedFilter.split(/\s+/);
|
||||
const terms = splitSearchTerms(normalizedFilter);
|
||||
|
||||
// First, try exact substring matching
|
||||
const exactMatches = filterDataExact(data, filterKeys, terms);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
state as litState,
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { SearchHighlight } from "../common/string/search-highlight";
|
||||
|
||||
interface State {
|
||||
bold: boolean;
|
||||
@@ -33,6 +34,8 @@ export class HaAnsiToHtml extends LitElement {
|
||||
|
||||
@litState() private _filter = "";
|
||||
|
||||
private _searchHighlight?: SearchHighlight;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
|
||||
}
|
||||
@@ -46,6 +49,11 @@ export class HaAnsiToHtml extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._searchHighlight?.clear();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
pre {
|
||||
margin: 0;
|
||||
@@ -114,11 +122,6 @@ export class HaAnsiToHtml extends LitElement {
|
||||
.bg-white {
|
||||
background-color: rgb(204, 204, 204);
|
||||
}
|
||||
|
||||
::highlight(search-results) {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
@@ -323,30 +326,29 @@ export class HaAnsiToHtml extends LitElement {
|
||||
this._filter = filter;
|
||||
const lines = this.shadowRoot?.querySelectorAll("div") || [];
|
||||
let numberOfFoundLines = 0;
|
||||
const filterLower = filter.toLowerCase();
|
||||
if (!filter) {
|
||||
lines.forEach((line) => {
|
||||
line.style.display = "";
|
||||
});
|
||||
numberOfFoundLines = lines.length;
|
||||
if (CSS.highlights) {
|
||||
CSS.highlights.delete("search-results");
|
||||
}
|
||||
this._searchHighlight?.clear();
|
||||
} else {
|
||||
const highlightRanges: Range[] = [];
|
||||
lines.forEach((line) => {
|
||||
if (!line.textContent?.toLowerCase().includes(filter.toLowerCase())) {
|
||||
if (!line.textContent?.toLowerCase().includes(filterLower)) {
|
||||
line.style.display = "none";
|
||||
} else {
|
||||
line.style.display = "";
|
||||
numberOfFoundLines++;
|
||||
if (CSS.highlights && line.firstChild !== null && line.textContent) {
|
||||
if (line.firstChild !== null && line.textContent) {
|
||||
const spansOfLine = line.querySelectorAll("span");
|
||||
spansOfLine.forEach((span) => {
|
||||
const text = span.textContent.toLowerCase();
|
||||
const indices: number[] = [];
|
||||
let startPos = 0;
|
||||
while (startPos < text.length) {
|
||||
const index = text.indexOf(filter.toLowerCase(), startPos);
|
||||
const index = text.indexOf(filterLower, startPos);
|
||||
if (index === -1) break;
|
||||
indices.push(index);
|
||||
startPos = index + filter.length;
|
||||
@@ -362,8 +364,11 @@ export class HaAnsiToHtml extends LitElement {
|
||||
}
|
||||
}
|
||||
});
|
||||
if (CSS.highlights) {
|
||||
CSS.highlights.set("search-results", new Highlight(...highlightRanges));
|
||||
if (this.shadowRoot) {
|
||||
this._getSearchHighlight(this.shadowRoot).applyFromRanges(
|
||||
highlightRanges,
|
||||
filter
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +380,13 @@ export class HaAnsiToHtml extends LitElement {
|
||||
this._pre.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
private _getSearchHighlight(root: ShadowRoot): SearchHighlight {
|
||||
if (!this._searchHighlight) {
|
||||
this._searchHighlight = new SearchHighlight(root);
|
||||
}
|
||||
return this._searchHighlight;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
IFuseOptions,
|
||||
} from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
import {
|
||||
normalizeSearchText,
|
||||
splitSearchTerms,
|
||||
} from "../common/string/search-query";
|
||||
|
||||
export interface FuseWeightedKey {
|
||||
name: string | string[];
|
||||
@@ -74,10 +78,7 @@ export function multiTermSearch<T>(
|
||||
fuseIndex?: FuseIndex<T>,
|
||||
options: IFuseOptions<T> = {}
|
||||
): T[] {
|
||||
const terms = search
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter((t) => t.trim());
|
||||
const terms = splitSearchTerms(normalizeSearchText(search));
|
||||
|
||||
if (!terms.length) {
|
||||
return items;
|
||||
@@ -147,10 +148,7 @@ export function multiTermSortedSearch<T>(
|
||||
fuseIndex?: FuseIndex<T>,
|
||||
options: IFuseOptions<T> = {}
|
||||
) {
|
||||
const terms = search
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter((t) => t.trim());
|
||||
const terms = splitSearchTerms(normalizeSearchText(search));
|
||||
|
||||
if (!terms.length) {
|
||||
return items;
|
||||
@@ -170,10 +168,6 @@ export function multiTermSortedSearch<T>(
|
||||
let termHits = 0;
|
||||
|
||||
terms.forEach((term) => {
|
||||
if (!term.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const termResults = searchTerm<T>(items, term, fuseIndex, {
|
||||
...options,
|
||||
shouldSort: false,
|
||||
|
||||
389
test/common/string/search-highlight.test.ts
Normal file
389
test/common/string/search-highlight.test.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SearchHighlight } from "../../../src/common/string/search-highlight";
|
||||
|
||||
interface MockHighlightApi {
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const originalCSS = globalThis.CSS;
|
||||
const originalHighlight = globalThis.Highlight;
|
||||
|
||||
const createShadowRoot = () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
return { host, root: host.attachShadow({ mode: "open" }) };
|
||||
};
|
||||
|
||||
const flushMutationObserver = async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
};
|
||||
|
||||
const installMockCustomHighlights = (): MockHighlightApi => {
|
||||
const highlights: MockHighlightApi = {
|
||||
set: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
class MockHighlight {
|
||||
public ranges: Range[];
|
||||
|
||||
public constructor(...ranges: Range[]) {
|
||||
this.ranges = ranges;
|
||||
}
|
||||
}
|
||||
|
||||
(globalThis as any).CSS = { highlights };
|
||||
(globalThis as any).Highlight = MockHighlight;
|
||||
|
||||
return highlights;
|
||||
};
|
||||
|
||||
describe("search highlight text rendering", () => {
|
||||
let searchHighlight: SearchHighlight;
|
||||
|
||||
beforeEach(() => {
|
||||
searchHighlight = new SearchHighlight();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("returns substring ranges", () => {
|
||||
expect(searchHighlight.getHighlightRanges("Hello World", "lo")).toEqual([
|
||||
{ start: 3, end: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty ranges for empty text or query", () => {
|
||||
expect(searchHighlight.getHighlightRanges("", "hello")).toEqual([]);
|
||||
expect(searchHighlight.getHighlightRanges("hello", "")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches diacritics-insensitive", () => {
|
||||
expect(searchHighlight.getHighlightRanges("Café", "cafe")).toEqual([
|
||||
{ start: 0, end: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated search terms", () => {
|
||||
expect(
|
||||
searchHighlight.getHighlightRanges("alpha alpha", "alpha alpha")
|
||||
).toEqual([
|
||||
{ start: 0, end: 5 },
|
||||
{ start: 6, end: 11 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps matches to original indexes for multi-unit characters", () => {
|
||||
expect(searchHighlight.getHighlightRanges("A😀B", "😀")).toEqual([
|
||||
{ start: 1, end: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges overlapping and adjacent ranges", () => {
|
||||
expect(searchHighlight.getHighlightRanges("abcd", "ab cd")).toEqual([
|
||||
{ start: 0, end: 4 },
|
||||
]);
|
||||
|
||||
expect(searchHighlight.getHighlightRanges("abcdef", "abc bcd")).toEqual([
|
||||
{ start: 0, end: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles terms that normalize to empty strings", () => {
|
||||
expect(searchHighlight.getHighlightRanges("abc", "\u0301")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles text that normalizes to empty string", () => {
|
||||
expect(searchHighlight.getHighlightRanges("\u0301", "a")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns ranges for multiple terms", () => {
|
||||
expect(
|
||||
searchHighlight.getHighlightRanges("alpha beta gamma", "alpha gamma")
|
||||
).toEqual([
|
||||
{ start: 0, end: 5 },
|
||||
{ start: 11, end: 16 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders highlighted text parts", () => {
|
||||
const result = searchHighlight.renderHighlightedText("Hello", "ell");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const parts = result as unknown as unknown[];
|
||||
expect(parts[0]).toBe("H");
|
||||
expect(parts[2]).toBe("o");
|
||||
});
|
||||
|
||||
it("returns original text when query is empty", () => {
|
||||
expect(searchHighlight.renderHighlightedText("Hello", "")).toBe("Hello");
|
||||
});
|
||||
|
||||
it("returns original value for null or undefined text", () => {
|
||||
expect(searchHighlight.renderHighlightedText(null, "a")).toBeNull();
|
||||
expect(searchHighlight.renderHighlightedText(undefined, "a")).toBe(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("returns original text when query does not match", () => {
|
||||
expect(searchHighlight.renderHighlightedText("Hello", "xyz")).toBe("Hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search highlight custom highlight API integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCSS === undefined) {
|
||||
delete (globalThis as any).CSS;
|
||||
} else {
|
||||
(globalThis as any).CSS = originalCSS;
|
||||
}
|
||||
|
||||
if (originalHighlight === undefined) {
|
||||
delete (globalThis as any).Highlight;
|
||||
} else {
|
||||
(globalThis as any).Highlight = originalHighlight;
|
||||
}
|
||||
});
|
||||
|
||||
it("is safe to call instance methods without a root", () => {
|
||||
const searchHighlight = new SearchHighlight();
|
||||
expect(() => searchHighlight.applyFromMarks("key")).not.toThrow();
|
||||
expect(() => searchHighlight.applyFromRanges([], "key")).not.toThrow();
|
||||
expect(() => searchHighlight.clear()).not.toThrow();
|
||||
});
|
||||
|
||||
it("injects style for highlight pseudo-element when supported", () => {
|
||||
installMockCustomHighlights();
|
||||
const { root } = createShadowRoot();
|
||||
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
expect(searchHighlight).toBeDefined();
|
||||
|
||||
const style = root.querySelector("style");
|
||||
expect(style).toBeTruthy();
|
||||
expect(style!.textContent).toContain("::highlight(ha-search-");
|
||||
expect(style!.textContent).toContain(".ha-highlight");
|
||||
});
|
||||
|
||||
it("still injects marker style when custom highlights are unavailable", () => {
|
||||
(globalThis as any).CSS = {};
|
||||
const { root } = createShadowRoot();
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
expect(searchHighlight).toBeDefined();
|
||||
expect(root.querySelector("style")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies and clears highlights based on mark nodes", () => {
|
||||
const highlights = installMockCustomHighlights();
|
||||
const { root } = createShadowRoot();
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "ha-highlight";
|
||||
mark.append(document.createComment("?lit$marker$"));
|
||||
mark.append(document.createTextNode("Alpha"));
|
||||
|
||||
const nonTextMark = document.createElement("mark");
|
||||
nonTextMark.className = "ha-highlight";
|
||||
const nested = document.createElement("span");
|
||||
nested.textContent = "ignored";
|
||||
nonTextMark.append(nested);
|
||||
|
||||
root.append(mark, nonTextMark);
|
||||
|
||||
searchHighlight.applyFromMarks("k1");
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
|
||||
searchHighlight.applyFromMarks("k1");
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
|
||||
searchHighlight.applyFromMarks("k2");
|
||||
expect(highlights.set).toHaveBeenCalledTimes(2);
|
||||
|
||||
mark.remove();
|
||||
nonTextMark.remove();
|
||||
searchHighlight.applyFromMarks("k3");
|
||||
expect(highlights.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies range highlights and skips only exact duplicate range positions", () => {
|
||||
const highlights = installMockCustomHighlights();
|
||||
const { root } = createShadowRoot();
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
|
||||
const textNode = document.createTextNode("abcdef");
|
||||
root.append(textNode);
|
||||
|
||||
const range = new Range();
|
||||
range.setStart(textNode, 1);
|
||||
range.setEnd(textNode, 3);
|
||||
|
||||
const movedRange = new Range();
|
||||
movedRange.setStart(textNode, 2);
|
||||
movedRange.setEnd(textNode, 4);
|
||||
|
||||
searchHighlight.applyFromRanges([range], "same");
|
||||
searchHighlight.applyFromRanges([range], "same");
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
|
||||
searchHighlight.applyFromRanges([movedRange], "same");
|
||||
expect(highlights.set).toHaveBeenCalledTimes(2);
|
||||
|
||||
searchHighlight.applyFromRanges([], "clear");
|
||||
expect(highlights.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("observes mark mutations and re-applies highlights", async () => {
|
||||
const highlights = installMockCustomHighlights();
|
||||
const { root } = createShadowRoot();
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "ha-highlight";
|
||||
mark.append(document.createTextNode("Alpha"));
|
||||
root.append(mark);
|
||||
|
||||
searchHighlight.startAutoSyncFromMarks(() => "key");
|
||||
await flushMutationObserver();
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
|
||||
(mark.firstChild as Text).textContent = "Alphabet";
|
||||
await flushMutationObserver();
|
||||
expect(highlights.set).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ignores unrelated mutations while auto-syncing", async () => {
|
||||
const highlights = installMockCustomHighlights();
|
||||
const { root } = createShadowRoot();
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
const applyFromMarksSpy = vi.spyOn(searchHighlight, "applyFromMarks");
|
||||
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "ha-highlight";
|
||||
mark.append(document.createTextNode("Alpha"));
|
||||
root.append(mark);
|
||||
|
||||
const unrelated = document.createElement("div");
|
||||
unrelated.append(document.createTextNode("outside"));
|
||||
root.append(unrelated);
|
||||
|
||||
searchHighlight.startAutoSyncFromMarks(() => "key");
|
||||
await flushMutationObserver();
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
expect(applyFromMarksSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
(unrelated.firstChild as Text).textContent = "outside-updated";
|
||||
await flushMutationObserver();
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
expect(applyFromMarksSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can observe a specific subtree", async () => {
|
||||
const highlights = installMockCustomHighlights();
|
||||
const { root } = createShadowRoot();
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
|
||||
const observed = document.createElement("div");
|
||||
const unobserved = document.createElement("div");
|
||||
root.append(observed, unobserved);
|
||||
|
||||
const observedMark = document.createElement("mark");
|
||||
observedMark.className = "ha-highlight";
|
||||
observedMark.append(document.createTextNode("Alpha"));
|
||||
observed.append(observedMark);
|
||||
|
||||
const unobservedMark = document.createElement("mark");
|
||||
unobservedMark.className = "ha-highlight";
|
||||
unobservedMark.append(document.createTextNode("Beta"));
|
||||
unobserved.append(unobservedMark);
|
||||
|
||||
searchHighlight.startAutoSyncFromMarks(() => "key", observed);
|
||||
await flushMutationObserver();
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
|
||||
(unobservedMark.firstChild as Text).textContent = "Beta-updated";
|
||||
await flushMutationObserver();
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
|
||||
(observedMark.firstChild as Text).textContent = "Alpha-updated";
|
||||
await flushMutationObserver();
|
||||
expect(highlights.set).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops observing mark mutations when stopped", async () => {
|
||||
const highlights = installMockCustomHighlights();
|
||||
const { root } = createShadowRoot();
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "ha-highlight";
|
||||
mark.append(document.createTextNode("Alpha"));
|
||||
root.append(mark);
|
||||
|
||||
searchHighlight.startAutoSyncFromMarks(() => "key");
|
||||
await flushMutationObserver();
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
|
||||
searchHighlight.stopAutoSyncFromMarks();
|
||||
(mark.firstChild as Text).textContent = "Alphabet";
|
||||
await flushMutationObserver();
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears highlights when observed key becomes empty", async () => {
|
||||
const highlights = installMockCustomHighlights();
|
||||
const { root } = createShadowRoot();
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "ha-highlight";
|
||||
mark.append(document.createTextNode("Alpha"));
|
||||
root.append(mark);
|
||||
|
||||
let key = "key";
|
||||
searchHighlight.startAutoSyncFromMarks(() => key);
|
||||
await flushMutationObserver();
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
|
||||
key = " ";
|
||||
(mark.firstChild as Text).textContent = "Alphabet";
|
||||
await flushMutationObserver();
|
||||
expect(highlights.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can apply highlights if support is added after construction", () => {
|
||||
(globalThis as any).CSS = {};
|
||||
const { root } = createShadowRoot();
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
const highlights = installMockCustomHighlights();
|
||||
|
||||
const textNode = document.createTextNode("abc");
|
||||
root.append(textNode);
|
||||
const range = new Range();
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, 1);
|
||||
|
||||
searchHighlight.applyFromRanges([range], "late-support");
|
||||
expect(highlights.set).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clear is safe even without CSS.highlights", () => {
|
||||
installMockCustomHighlights();
|
||||
const { root } = createShadowRoot();
|
||||
const searchHighlight = new SearchHighlight(root);
|
||||
|
||||
(globalThis as any).CSS = {};
|
||||
expect(() => searchHighlight.clear()).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user