import { LitElement, html, css } from 'lit';
import type { TemplateResult } from 'lit';
import { property, query } from 'lit/decorators.js';
export type MarkVariant = 'warning' | 'info' | 'success' | 'error' | 'primary' | 'secondary' | 'monochrome';
export interface MarkProps {
variant?: MarkVariant;
search?: string;
caseSensitive?: boolean;
matchAll?: boolean;
}
/**
* @element ag-mark
* @csspart ag-mark - The root wrapping mark element in static mode.
*/
export class Mark extends LitElement implements MarkProps {
static styles = css`
:host {
display: inline;
}
.mark {
border-radius: var(--ag-radius-xs);
background-color: var(--ag-warning-background);
color: var(--ag-warning-text);
}
.mark::before,
.mark::after {
clip-path: inset(100%);
clip: rect(1px, 1px, 1px, 1px);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.mark::before {
content: " [highlight start] ";
}
.mark::after {
content: " [highlight end] ";
}
.mark-warning {
background-color: var(--ag-warning-background);
color: var(--ag-warning-text);
}
.mark-info {
background-color: var(--ag-info-background);
color: var(--ag-info-text);
}
.mark-success {
background-color: var(--ag-success-background);
color: var(--ag-success-text);
}
.mark-error {
background-color: var(--ag-danger-background);
color: var(--ag-danger-text);
}
.mark-primary {
background-color: var(--ag-primary-background);
color: var(--ag-primary-text);
}
.mark-secondary {
background-color: var(--ag-background-secondary);
color: var(--ag-text-primary);
}
.mark-monochrome {
background-color: var(--ag-background-secondary-inverted);
color: var(--ag-text-primary-inverted);
}
`;
@property({ type: String })
declare public variant: MarkVariant;
@property({ type: String })
declare search?: string;
@property({ type: Boolean, attribute: 'case-sensitive' })
declare caseSensitive: boolean;
@property({ type: Boolean, attribute: 'match-all' })
declare matchAll: boolean;
@query('slot')
private _slot!: HTMLSlotElement;
private get _textContent(): string {
// Return empty string if the slot is not yet available
if (!this._slot) return '';
return this._slot.assignedNodes({ flatten: true }).map(node => node.textContent ?? '').join('');
}
constructor() {
super();
this.variant = 'warning';
this.search = undefined;
this.caseSensitive = false;
this.matchAll = false;
}
private _onSlotChange() {
this.requestUpdate();
}
private _renderStatic() {
return html`
`;
}
private _renderReactive() {
const text = this._textContent;
// The hidden slot is our source of truth for the light DOM content.
const hiddenSlot = html``;
if (!text) {
return hiddenSlot;
}
if (!this.search) {
return html`${hiddenSlot}${text}`;
}
// To prevent user input from breaking the RegExp, we escape special characters.
// This treats the search string as a literal sequence of characters.
const escapedSearch = this.search.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
if (!escapedSearch) {
return html`${hiddenSlot}${text}`;
}
let flags = this.matchAll ? 'g' : '';
if (!this.caseSensitive) {
flags += 'i';
}
const regex = new RegExp(escapedSearch, flags);
const parts: (string | TemplateResult)[] = [];
// We use two different strategies depending on whether we need to find all matches
// or just the first one.
if (this.matchAll) {
// String.prototype.matchAll() returns an iterator of all results, which we
// spread into an array to work with it.
const matches = [...text.matchAll(regex)];
if (!matches.length) return html`${hiddenSlot}${text}`;
let lastIndex = 0;
// We loop through each match, pushing the text *before* the match,
// and then the highlighted match itself.
for (const match of matches) {
parts.push(text.substring(lastIndex, match.index));
parts.push(html`${match[0]}`);
lastIndex = (match.index ?? 0) + match[0].length;
}
// Finally, we add any remaining text after the last match.
parts.push(text.substring(lastIndex));
} else {
// For a single match, we can't use a global regex with `split`, as it would
// incorrectly remove all occurrences. Instead, we find only the first match.
const match = text.match(regex);
if (match && match.index !== undefined) {
// We manually slice the string into three parts: before the match, the
// highlighted match, and after the match.
const pre = text.substring(0, match.index);
const highlighted = html`${match[0]}`;
const post = text.substring(match.index + match[0].length);
parts.push(pre, highlighted, post);
} else {
// If no match is found, we just push the original text.
parts.push(text);
}
}
return html`${hiddenSlot}${parts}`;
}
render() {
if (!this.search) {
return this._renderStatic();
}
return this._renderReactive();
}
}