Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<FluentTextInput @bind-Value="@Highlight" Immediate="true" />

<div style="@CommonStyles.NeutralBorderShadow4" class="@Padding.All2 @Margin.All2">
<FluentHighlighter HighlightedText="@Highlight"
Delimiters=" ,;"
Text="@Text" />
</div>

@code
{
static string Text = SampleData.Text.GenerateLoremIpsum();
string Highlight = "Lorem";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<FluentTextInput @bind-Value="@Highlight" Immediate="true" /><br/>
<FluentSwitch @bind-Value="@UntilNextBoundary" Label="UntilNextBoundary" />
<FluentSwitch @bind-Value="@Styled" Label="Apply a Style" Margin="@Margin.Start2" />

<div style="@CommonStyles.NeutralBorderShadow4" class="@Padding.All2 @Margin.All2">
<FluentHighlighter HighlightedText="@Highlight"
UntilNextBoundary="@UntilNextBoundary"
Style="@(Styled ? CommonStyles.BrandBackground : null)"
Delimiters=" ,;"
Text="@Text" />
</div>

@code
{
static string Text = SampleData.Text.GenerateLoremIpsum();
string Highlight = "Lore, ips";
bool Styled = false;
bool UntilNextBoundary = false;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: Highlighter
route: /Highlighter
---

# Highlighter

A component which highlights words or phrases within text.
The highlighter can be used in combination with any other component.

## General usage

{{ HighlighterDefault }}

## Multiple Highlights

In addition to `HighlightedText` parameter which accepts a single text fragment in the form of an string,
the `Delimiters` parameter define a list of chars which can be used to highlight several text fragments.
See this example where `Delimiters=" ,;"` where you can use space, comma and semicolon to hihlight the search text.

Set the `UntilNextBoundary="true"` parameter if you want to highlight the text until the next regex boundary occurs.
This is useful when you want to highlight a word and all the text until the next **space**.
In this example, the `HighlightedText="Lore, ips"` and the component will highlight the text until the next boundary which is a **space**.

{{ HighlighterDelimiters }}

## API FluentHighlighter

{{ API Type=FluentHighlighter }}

## Migrating to v5

No changes
15 changes: 15 additions & 0 deletions src/Core/Components/Highlighter/FluentHighlighter.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@namespace Microsoft.FluentUI.AspNetCore.Components
@using System.Text.RegularExpressions
@inherits FluentComponentBase

@foreach (var fragment in _fragments.Span)
{
if (!string.IsNullOrWhiteSpace(_regex) && Regex.IsMatch(fragment, _regex, CaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase))
{
<mark id="@Id" class="@ClassValue" style="@StyleValue" @attributes="@AdditionalAttributes">@fragment</mark>
}
else
{
@fragment
}
}
64 changes: 64 additions & 0 deletions src/Core/Components/Highlighter/FluentHighlighter.razor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// ------------------------------------------------------------------------
// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------

using Microsoft.AspNetCore.Components;

namespace Microsoft.FluentUI.AspNetCore.Components;

/// <summary>
/// A component which highlights words or phrases within text.
/// </summary>
public partial class FluentHighlighter : FluentComponentBase
{
private Memory<string> _fragments;
private string _regex = string.Empty;

/// <summary />
protected string? ClassValue => DefaultClassBuilder
.Build();

/// <summary />
protected string? StyleValue => DefaultStyleBuilder
.Build();

/// <summary>
/// Gets or sets a value indicating whether the highlighted text is case sensitive.
/// </summary>
[Parameter]
public bool CaseSensitive { get; set; } = false;

/// <summary>
/// Gets or sets the fragment of text to be highlighted.
/// </summary>
[Parameter]
public string HighlightedText { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the whole text in which a fragment will be highlighted.
/// </summary>
[Parameter]
public string Text { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the list of delimiters chars. Example: " ,;".
/// </summary>
[Parameter]
public string Delimiters { get; set; } = string.Empty;

/// <summary>
/// If true, highlights the text until the next regex boundary.
/// </summary>
[Parameter]
public bool UntilNextBoundary { get; set; }

/// <summary />
protected override void OnParametersSet()
{
var highlightedTexts = string.IsNullOrEmpty(Delimiters)
? [HighlightedText]
: HighlightedText.Split(Delimiters.ToCharArray());

_fragments = HighlighterSplitter.GetFragments(Text, highlightedTexts, out _regex, CaseSensitive, UntilNextBoundary);
}
}
136 changes: 136 additions & 0 deletions src/Core/Components/Highlighter/HighlighterSplitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// ------------------------------------------------------------------------
// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------------------

using System.Text;
using System.Text.RegularExpressions;

namespace Microsoft.FluentUI.AspNetCore.Components;

/// <summary>
/// Splits the text into fragments, according to the text to be highlighted
/// </summary>
/// <remarks>Inspired from https://github.com/MudBlazor</remarks>
internal sealed class HighlighterSplitter
{
private static readonly TimeSpan _regExMatchTimeout = TimeSpan.FromMilliseconds(100);
private const string NextBoundary = ".*?\\b";

private static StringBuilder? _stringBuilderCached;

/// <summary>
/// Splits the text into fragments, according to the
/// text to be highlighted
/// </summary>
/// <param name="text">The whole text</param>
/// <param name="highlightedTexts">The texts to be highlighted</param>
/// <param name="regex">Regex expression that was used to split fragments.</param>
/// <param name="caseSensitive">Whether it's case sensitive or not</param>
/// <param name="untilNextBoundary">If true, splits until the next regex boundary</param>
/// <returns></returns>
internal static Memory<string> GetFragments(
string text,
IEnumerable<string> highlightedTexts,
out string regex,
bool caseSensitive = false,
bool untilNextBoundary = false)
{
if (string.IsNullOrEmpty(text))
{
regex = string.Empty;
return Memory<string>.Empty;
}

var builder = GetStringBuilder();
regex = BuildRegexPattern(builder, highlightedTexts, untilNextBoundary);

if (string.IsNullOrEmpty(regex))
{
return new string[] { text };
}

var splits = SplitText(text, regex, caseSensitive);
return FilterEmptyFragments(splits);
}

/// <summary />
private static StringBuilder GetStringBuilder()
{
return Interlocked.Exchange(ref _stringBuilderCached, value: null) ?? new StringBuilder();
}

/// <summary />
private static string BuildRegexPattern(StringBuilder builder, IEnumerable<string> highlightedTexts, bool untilNextBoundary)
{
builder.Append("((?:");
var hasAtLeastOnePattern = false;

if (highlightedTexts is not null)
{
foreach (var substring in highlightedTexts)
{
if (string.IsNullOrEmpty(substring))
{
continue;
}

if (hasAtLeastOnePattern)
{
builder.Append(")|(?:");
}

AppendPattern(builder, substring, untilNextBoundary);
hasAtLeastOnePattern = true;
}
}

if (hasAtLeastOnePattern)
{
builder.Append("))");
}
else
{
builder.Clear();
_stringBuilderCached = builder;
return string.Empty;
}

var regex = builder.ToString();
builder.Clear();
_stringBuilderCached = builder;
return regex;
}

/// <summary />
private static void AppendPattern(StringBuilder builder, string value, bool untilNextBoundary)
{
value = Regex.Escape(value);
builder.Append(value);
if (untilNextBoundary)
{
builder.Append(NextBoundary);
}
}

/// <summary />
private static string[] SplitText(string text, string regex, bool caseSensitive)
{
return Regex.Split(text, regex, caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase, _regExMatchTimeout);
}

/// <summary />
private static Memory<string> FilterEmptyFragments(string[] splits)
{
var length = 0;
for (var i = 0; i < splits.Length; i++)
{
if (!string.IsNullOrEmpty(splits[i]))
{
splits[length++] = splits[i];
}
}

Array.Clear(splits, length, splits.Length - length);
return splits.AsMemory(0, length);
}
}
1 change: 1 addition & 0 deletions tests/Core/Components/Base/ComponentBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public class ComponentBaseTests : Bunit.TestContext
{ typeof(FluentRadioGroup<>), Loader.MakeGenericType(typeof(string)) },
//{ typeof(FluentRadio<>), Loader.MakeGenericType(typeof(string)) },
{ typeof(FluentTooltip), Loader.Default.WithRequiredParameter("Anchor", "MyButton").WithRequiredParameter("UseTooltipService", false)},
{ typeof(FluentHighlighter), Loader.Default.WithRequiredParameter("HighlightedText", "AB").WithRequiredParameter("Text", "ABCDEF")},
};

/// <summary />
Expand Down
103 changes: 103 additions & 0 deletions tests/Core/Components/Highlighter/FluentHighlighterTests.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
@using Microsoft.FluentUI.AspNetCore.Components.Utilities
@using Xunit;
@using Microsoft.FluentUI.AspNetCore.Components.Tests.Samples;
@inherits Bunit.TestContext

@code
{
public FluentHighlighterTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddFluentUIComponents();
}

[Fact]
public void FluentHighlighter_Default()
{
// Arrange
var cut = Render(@<FluentHighlighter HighlightedText="ipsum" Text="Lorem ipsum dolor sit amity. Lorem ipsum dolor sit amity. Lorem ipsum dolor sit amity." />);

// Act
var highlightedText = cut.FindAll("mark");

// Assert
Assert.Equal(3, highlightedText.Count);
Assert.Equal("ipsum", highlightedText[0].InnerHtml);
}

[Fact]
public void FluentHighlighter_CaseSensitive()
{
// Arrange
var cut = Render(@<FluentHighlighter HighlightedText="Ipsum" Text="Lorem ipsum dolor sit amity. Lorem Ipsum dolor sit amity." CaseSensitive="true" />);

// Act
var highlightedText = cut.FindAll("mark");

// Assert
Assert.Single(highlightedText);
Assert.Equal("Ipsum", highlightedText[0].InnerHtml);
}

[Fact]
public void FluentHighlighter_WithDelimiters()
{
// Arrange
var cut = Render(@<FluentHighlighter HighlightedText="ipsum,lorem" Text="Lorem ipsum, dolor sit amity. Lorem ipsum. dolor sit amity." Delimiters=".," />);

// Act
var highlightedText = cut.FindAll("mark");

// Assert
Assert.Equal(4, highlightedText.Count);
Assert.Equal("Lorem", highlightedText[0].InnerHtml);
Assert.Equal("ipsum", highlightedText[1].InnerHtml);
Assert.Equal("Lorem", highlightedText[2].InnerHtml);
Assert.Equal("ipsum", highlightedText[3].InnerHtml);
}

[Fact]
public void FluentHighlighter_UntilNextBoundary()
{
// Arrange
var cut = Render(@<FluentHighlighter HighlightedText="Lore, ips" Text="Lorem ipsum, dolor sit amity. Lorem ipsum. dolor sit amity." Delimiters=".," UntilNextBoundary="true" />);

// Act
var highlightedText = cut.FindAll("mark");

// Assert
Assert.Equal(4, highlightedText.Count);
Assert.Equal("Lorem", highlightedText[0].InnerHtml);
Assert.Equal(" ipsum", highlightedText[1].InnerHtml);
Assert.Equal("Lorem", highlightedText[2].InnerHtml);
Assert.Equal(" ipsum", highlightedText[3].InnerHtml);
}

[Fact]
public void FluentHighlighter_HighlightedText_Empty()
{
// Arrange
var cut = Render(@<FluentHighlighter Text="Lorem ipsum dolor sit amity." />);

// Act
var highlightedText = cut.FindAll("mark");

// Assert
Assert.Empty(highlightedText);
Assert.Equal("Lorem ipsum dolor sit amity.", cut.Markup);
}

[Fact]
public void FluentHighlighter_Text_Empty()
{
// Arrange
var cut = Render(@<FluentHighlighter HighlightedText="Lorem" />);

// Act
var highlightedText = cut.FindAll("mark");

// Assert
Assert.Empty(highlightedText);
Assert.Empty(cut.Markup);
}
}