Skip to content

Commit 986ab59

Browse files
authored
[dev-v5] FluentHighlighter (#3708)
1 parent 360e49d commit 986ab59

File tree

8 files changed

+384
-0
lines changed

8 files changed

+384
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<FluentTextInput @bind-Value="@Highlight" Immediate="true" />
2+
3+
<div style="@CommonStyles.NeutralBorderShadow4" class="@Padding.All2 @Margin.All2">
4+
<FluentHighlighter HighlightedText="@Highlight"
5+
Delimiters=" ,;"
6+
Text="@Text" />
7+
</div>
8+
9+
@code
10+
{
11+
static string Text = SampleData.Text.GenerateLoremIpsum();
12+
string Highlight = "Lorem";
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<FluentTextInput @bind-Value="@Highlight" Immediate="true" /><br/>
2+
<FluentSwitch @bind-Value="@UntilNextBoundary" Label="UntilNextBoundary" />
3+
<FluentSwitch @bind-Value="@Styled" Label="Apply a Style" Margin="@Margin.Start2" />
4+
5+
<div style="@CommonStyles.NeutralBorderShadow4" class="@Padding.All2 @Margin.All2">
6+
<FluentHighlighter HighlightedText="@Highlight"
7+
UntilNextBoundary="@UntilNextBoundary"
8+
Style="@(Styled ? CommonStyles.BrandBackground : null)"
9+
Delimiters=" ,;"
10+
Text="@Text" />
11+
</div>
12+
13+
@code
14+
{
15+
static string Text = SampleData.Text.GenerateLoremIpsum();
16+
string Highlight = "Lore, ips";
17+
bool Styled = false;
18+
bool UntilNextBoundary = false;
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
title: Highlighter
3+
route: /Highlighter
4+
---
5+
6+
# Highlighter
7+
8+
A component which highlights words or phrases within text.
9+
The highlighter can be used in combination with any other component.
10+
11+
## General usage
12+
13+
{{ HighlighterDefault }}
14+
15+
## Multiple Highlights
16+
17+
In addition to `HighlightedText` parameter which accepts a single text fragment in the form of an string,
18+
the `Delimiters` parameter define a list of chars which can be used to highlight several text fragments.
19+
See this example where `Delimiters=" ,;"` where you can use space, comma and semicolon to hihlight the search text.
20+
21+
Set the `UntilNextBoundary="true"` parameter if you want to highlight the text until the next regex boundary occurs.
22+
This is useful when you want to highlight a word and all the text until the next **space**.
23+
In this example, the `HighlightedText="Lore, ips"` and the component will highlight the text until the next boundary which is a **space**.
24+
25+
{{ HighlighterDelimiters }}
26+
27+
## API FluentHighlighter
28+
29+
{{ API Type=FluentHighlighter }}
30+
31+
## Migrating to v5
32+
33+
No changes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@namespace Microsoft.FluentUI.AspNetCore.Components
2+
@using System.Text.RegularExpressions
3+
@inherits FluentComponentBase
4+
5+
@foreach (var fragment in _fragments.Span)
6+
{
7+
if (!string.IsNullOrWhiteSpace(_regex) && Regex.IsMatch(fragment, _regex, CaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase))
8+
{
9+
<mark id="@Id" class="@ClassValue" style="@StyleValue" @attributes="@AdditionalAttributes">@fragment</mark>
10+
}
11+
else
12+
{
13+
@fragment
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// ------------------------------------------------------------------------
2+
// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
3+
// ------------------------------------------------------------------------
4+
5+
using Microsoft.AspNetCore.Components;
6+
7+
namespace Microsoft.FluentUI.AspNetCore.Components;
8+
9+
/// <summary>
10+
/// A component which highlights words or phrases within text.
11+
/// </summary>
12+
public partial class FluentHighlighter : FluentComponentBase
13+
{
14+
private Memory<string> _fragments;
15+
private string _regex = string.Empty;
16+
17+
/// <summary />
18+
protected string? ClassValue => DefaultClassBuilder
19+
.Build();
20+
21+
/// <summary />
22+
protected string? StyleValue => DefaultStyleBuilder
23+
.Build();
24+
25+
/// <summary>
26+
/// Gets or sets a value indicating whether the highlighted text is case sensitive.
27+
/// </summary>
28+
[Parameter]
29+
public bool CaseSensitive { get; set; } = false;
30+
31+
/// <summary>
32+
/// Gets or sets the fragment of text to be highlighted.
33+
/// </summary>
34+
[Parameter]
35+
public string HighlightedText { get; set; } = string.Empty;
36+
37+
/// <summary>
38+
/// Gets or sets the whole text in which a fragment will be highlighted.
39+
/// </summary>
40+
[Parameter]
41+
public string Text { get; set; } = string.Empty;
42+
43+
/// <summary>
44+
/// Gets or sets the list of delimiters chars. Example: " ,;".
45+
/// </summary>
46+
[Parameter]
47+
public string Delimiters { get; set; } = string.Empty;
48+
49+
/// <summary>
50+
/// If true, highlights the text until the next regex boundary.
51+
/// </summary>
52+
[Parameter]
53+
public bool UntilNextBoundary { get; set; }
54+
55+
/// <summary />
56+
protected override void OnParametersSet()
57+
{
58+
var highlightedTexts = string.IsNullOrEmpty(Delimiters)
59+
? [HighlightedText]
60+
: HighlightedText.Split(Delimiters.ToCharArray());
61+
62+
_fragments = HighlighterSplitter.GetFragments(Text, highlightedTexts, out _regex, CaseSensitive, UntilNextBoundary);
63+
}
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// ------------------------------------------------------------------------
2+
// MIT License - Copyright (c) Microsoft Corporation. All rights reserved.
3+
// ------------------------------------------------------------------------
4+
5+
using System.Text;
6+
using System.Text.RegularExpressions;
7+
8+
namespace Microsoft.FluentUI.AspNetCore.Components;
9+
10+
/// <summary>
11+
/// Splits the text into fragments, according to the text to be highlighted
12+
/// </summary>
13+
/// <remarks>Inspired from https://github.com/MudBlazor</remarks>
14+
internal sealed class HighlighterSplitter
15+
{
16+
private static readonly TimeSpan _regExMatchTimeout = TimeSpan.FromMilliseconds(100);
17+
private const string NextBoundary = ".*?\\b";
18+
19+
private static StringBuilder? _stringBuilderCached;
20+
21+
/// <summary>
22+
/// Splits the text into fragments, according to the
23+
/// text to be highlighted
24+
/// </summary>
25+
/// <param name="text">The whole text</param>
26+
/// <param name="highlightedTexts">The texts to be highlighted</param>
27+
/// <param name="regex">Regex expression that was used to split fragments.</param>
28+
/// <param name="caseSensitive">Whether it's case sensitive or not</param>
29+
/// <param name="untilNextBoundary">If true, splits until the next regex boundary</param>
30+
/// <returns></returns>
31+
internal static Memory<string> GetFragments(
32+
string text,
33+
IEnumerable<string> highlightedTexts,
34+
out string regex,
35+
bool caseSensitive = false,
36+
bool untilNextBoundary = false)
37+
{
38+
if (string.IsNullOrEmpty(text))
39+
{
40+
regex = string.Empty;
41+
return Memory<string>.Empty;
42+
}
43+
44+
var builder = GetStringBuilder();
45+
regex = BuildRegexPattern(builder, highlightedTexts, untilNextBoundary);
46+
47+
if (string.IsNullOrEmpty(regex))
48+
{
49+
return new string[] { text };
50+
}
51+
52+
var splits = SplitText(text, regex, caseSensitive);
53+
return FilterEmptyFragments(splits);
54+
}
55+
56+
/// <summary />
57+
private static StringBuilder GetStringBuilder()
58+
{
59+
return Interlocked.Exchange(ref _stringBuilderCached, value: null) ?? new StringBuilder();
60+
}
61+
62+
/// <summary />
63+
private static string BuildRegexPattern(StringBuilder builder, IEnumerable<string> highlightedTexts, bool untilNextBoundary)
64+
{
65+
builder.Append("((?:");
66+
var hasAtLeastOnePattern = false;
67+
68+
if (highlightedTexts is not null)
69+
{
70+
foreach (var substring in highlightedTexts)
71+
{
72+
if (string.IsNullOrEmpty(substring))
73+
{
74+
continue;
75+
}
76+
77+
if (hasAtLeastOnePattern)
78+
{
79+
builder.Append(")|(?:");
80+
}
81+
82+
AppendPattern(builder, substring, untilNextBoundary);
83+
hasAtLeastOnePattern = true;
84+
}
85+
}
86+
87+
if (hasAtLeastOnePattern)
88+
{
89+
builder.Append("))");
90+
}
91+
else
92+
{
93+
builder.Clear();
94+
_stringBuilderCached = builder;
95+
return string.Empty;
96+
}
97+
98+
var regex = builder.ToString();
99+
builder.Clear();
100+
_stringBuilderCached = builder;
101+
return regex;
102+
}
103+
104+
/// <summary />
105+
private static void AppendPattern(StringBuilder builder, string value, bool untilNextBoundary)
106+
{
107+
value = Regex.Escape(value);
108+
builder.Append(value);
109+
if (untilNextBoundary)
110+
{
111+
builder.Append(NextBoundary);
112+
}
113+
}
114+
115+
/// <summary />
116+
private static string[] SplitText(string text, string regex, bool caseSensitive)
117+
{
118+
return Regex.Split(text, regex, caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase, _regExMatchTimeout);
119+
}
120+
121+
/// <summary />
122+
private static Memory<string> FilterEmptyFragments(string[] splits)
123+
{
124+
var length = 0;
125+
for (var i = 0; i < splits.Length; i++)
126+
{
127+
if (!string.IsNullOrEmpty(splits[i]))
128+
{
129+
splits[length++] = splits[i];
130+
}
131+
}
132+
133+
Array.Clear(splits, length, splits.Length - length);
134+
return splits.AsMemory(0, length);
135+
}
136+
}

tests/Core/Components/Base/ComponentBaseTests.cs

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public class ComponentBaseTests : Bunit.TestContext
3838
{ typeof(FluentRadioGroup<>), Loader.MakeGenericType(typeof(string)) },
3939
//{ typeof(FluentRadio<>), Loader.MakeGenericType(typeof(string)) },
4040
{ typeof(FluentTooltip), Loader.Default.WithRequiredParameter("Anchor", "MyButton").WithRequiredParameter("UseTooltipService", false)},
41+
{ typeof(FluentHighlighter), Loader.Default.WithRequiredParameter("HighlightedText", "AB").WithRequiredParameter("Text", "ABCDEF")},
4142
};
4243

4344
/// <summary />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
@using Microsoft.FluentUI.AspNetCore.Components.Utilities
2+
@using Xunit;
3+
@using Microsoft.FluentUI.AspNetCore.Components.Tests.Samples;
4+
@inherits Bunit.TestContext
5+
6+
@code
7+
{
8+
public FluentHighlighterTests()
9+
{
10+
JSInterop.Mode = JSRuntimeMode.Loose;
11+
Services.AddFluentUIComponents();
12+
}
13+
14+
[Fact]
15+
public void FluentHighlighter_Default()
16+
{
17+
// Arrange
18+
var cut = Render(@<FluentHighlighter HighlightedText="ipsum" Text="Lorem ipsum dolor sit amity. Lorem ipsum dolor sit amity. Lorem ipsum dolor sit amity." />);
19+
20+
// Act
21+
var highlightedText = cut.FindAll("mark");
22+
23+
// Assert
24+
Assert.Equal(3, highlightedText.Count);
25+
Assert.Equal("ipsum", highlightedText[0].InnerHtml);
26+
}
27+
28+
[Fact]
29+
public void FluentHighlighter_CaseSensitive()
30+
{
31+
// Arrange
32+
var cut = Render(@<FluentHighlighter HighlightedText="Ipsum" Text="Lorem ipsum dolor sit amity. Lorem Ipsum dolor sit amity." CaseSensitive="true" />);
33+
34+
// Act
35+
var highlightedText = cut.FindAll("mark");
36+
37+
// Assert
38+
Assert.Single(highlightedText);
39+
Assert.Equal("Ipsum", highlightedText[0].InnerHtml);
40+
}
41+
42+
[Fact]
43+
public void FluentHighlighter_WithDelimiters()
44+
{
45+
// Arrange
46+
var cut = Render(@<FluentHighlighter HighlightedText="ipsum,lorem" Text="Lorem ipsum, dolor sit amity. Lorem ipsum. dolor sit amity." Delimiters=".," />);
47+
48+
// Act
49+
var highlightedText = cut.FindAll("mark");
50+
51+
// Assert
52+
Assert.Equal(4, highlightedText.Count);
53+
Assert.Equal("Lorem", highlightedText[0].InnerHtml);
54+
Assert.Equal("ipsum", highlightedText[1].InnerHtml);
55+
Assert.Equal("Lorem", highlightedText[2].InnerHtml);
56+
Assert.Equal("ipsum", highlightedText[3].InnerHtml);
57+
}
58+
59+
[Fact]
60+
public void FluentHighlighter_UntilNextBoundary()
61+
{
62+
// Arrange
63+
var cut = Render(@<FluentHighlighter HighlightedText="Lore, ips" Text="Lorem ipsum, dolor sit amity. Lorem ipsum. dolor sit amity." Delimiters=".," UntilNextBoundary="true" />);
64+
65+
// Act
66+
var highlightedText = cut.FindAll("mark");
67+
68+
// Assert
69+
Assert.Equal(4, highlightedText.Count);
70+
Assert.Equal("Lorem", highlightedText[0].InnerHtml);
71+
Assert.Equal(" ipsum", highlightedText[1].InnerHtml);
72+
Assert.Equal("Lorem", highlightedText[2].InnerHtml);
73+
Assert.Equal(" ipsum", highlightedText[3].InnerHtml);
74+
}
75+
76+
[Fact]
77+
public void FluentHighlighter_HighlightedText_Empty()
78+
{
79+
// Arrange
80+
var cut = Render(@<FluentHighlighter Text="Lorem ipsum dolor sit amity." />);
81+
82+
// Act
83+
var highlightedText = cut.FindAll("mark");
84+
85+
// Assert
86+
Assert.Empty(highlightedText);
87+
Assert.Equal("Lorem ipsum dolor sit amity.", cut.Markup);
88+
}
89+
90+
[Fact]
91+
public void FluentHighlighter_Text_Empty()
92+
{
93+
// Arrange
94+
var cut = Render(@<FluentHighlighter HighlightedText="Lorem" />);
95+
96+
// Act
97+
var highlightedText = cut.FindAll("mark");
98+
99+
// Assert
100+
Assert.Empty(highlightedText);
101+
Assert.Empty(cut.Markup);
102+
}
103+
}

0 commit comments

Comments
 (0)