diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Highlighter/Examples/HighlighterDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Highlighter/Examples/HighlighterDefault.razor new file mode 100644 index 0000000000..4e36904f0c --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Highlighter/Examples/HighlighterDefault.razor @@ -0,0 +1,13 @@ + + +
+ +
+ +@code +{ + static string Text = SampleData.Text.GenerateLoremIpsum(); + string Highlight = "Lorem"; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Highlighter/Examples/HighlighterDelimiters.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Highlighter/Examples/HighlighterDelimiters.razor new file mode 100644 index 0000000000..3590ea0836 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Highlighter/Examples/HighlighterDelimiters.razor @@ -0,0 +1,19 @@ +
+ + + +
+ +
+ +@code +{ + static string Text = SampleData.Text.GenerateLoremIpsum(); + string Highlight = "Lore, ips"; + bool Styled = false; + bool UntilNextBoundary = false; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Highlighter/FluentHighlighter.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Highlighter/FluentHighlighter.md new file mode 100644 index 0000000000..18249dcdb6 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Highlighter/FluentHighlighter.md @@ -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 diff --git a/src/Core/Components/Highlighter/FluentHighlighter.razor b/src/Core/Components/Highlighter/FluentHighlighter.razor new file mode 100644 index 0000000000..c2721c965f --- /dev/null +++ b/src/Core/Components/Highlighter/FluentHighlighter.razor @@ -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)) + { + @fragment + } + else + { + @fragment + } +} diff --git a/src/Core/Components/Highlighter/FluentHighlighter.razor.cs b/src/Core/Components/Highlighter/FluentHighlighter.razor.cs new file mode 100644 index 0000000000..adab983e01 --- /dev/null +++ b/src/Core/Components/Highlighter/FluentHighlighter.razor.cs @@ -0,0 +1,64 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// A component which highlights words or phrases within text. +/// +public partial class FluentHighlighter : FluentComponentBase +{ + private Memory _fragments; + private string _regex = string.Empty; + + /// + protected string? ClassValue => DefaultClassBuilder + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .Build(); + + /// + /// Gets or sets a value indicating whether the highlighted text is case sensitive. + /// + [Parameter] + public bool CaseSensitive { get; set; } = false; + + /// + /// Gets or sets the fragment of text to be highlighted. + /// + [Parameter] + public string HighlightedText { get; set; } = string.Empty; + + /// + /// Gets or sets the whole text in which a fragment will be highlighted. + /// + [Parameter] + public string Text { get; set; } = string.Empty; + + /// + /// Gets or sets the list of delimiters chars. Example: " ,;". + /// + [Parameter] + public string Delimiters { get; set; } = string.Empty; + + /// + /// If true, highlights the text until the next regex boundary. + /// + [Parameter] + public bool UntilNextBoundary { get; set; } + + /// + protected override void OnParametersSet() + { + var highlightedTexts = string.IsNullOrEmpty(Delimiters) + ? [HighlightedText] + : HighlightedText.Split(Delimiters.ToCharArray()); + + _fragments = HighlighterSplitter.GetFragments(Text, highlightedTexts, out _regex, CaseSensitive, UntilNextBoundary); + } +} diff --git a/src/Core/Components/Highlighter/HighlighterSplitter.cs b/src/Core/Components/Highlighter/HighlighterSplitter.cs new file mode 100644 index 0000000000..7ea5834a39 --- /dev/null +++ b/src/Core/Components/Highlighter/HighlighterSplitter.cs @@ -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; + +/// +/// Splits the text into fragments, according to the text to be highlighted +/// +/// Inspired from https://github.com/MudBlazor +internal sealed class HighlighterSplitter +{ + private static readonly TimeSpan _regExMatchTimeout = TimeSpan.FromMilliseconds(100); + private const string NextBoundary = ".*?\\b"; + + private static StringBuilder? _stringBuilderCached; + + /// + /// Splits the text into fragments, according to the + /// text to be highlighted + /// + /// The whole text + /// The texts to be highlighted + /// Regex expression that was used to split fragments. + /// Whether it's case sensitive or not + /// If true, splits until the next regex boundary + /// + internal static Memory GetFragments( + string text, + IEnumerable highlightedTexts, + out string regex, + bool caseSensitive = false, + bool untilNextBoundary = false) + { + if (string.IsNullOrEmpty(text)) + { + regex = string.Empty; + return Memory.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); + } + + /// + private static StringBuilder GetStringBuilder() + { + return Interlocked.Exchange(ref _stringBuilderCached, value: null) ?? new StringBuilder(); + } + + /// + private static string BuildRegexPattern(StringBuilder builder, IEnumerable 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; + } + + /// + private static void AppendPattern(StringBuilder builder, string value, bool untilNextBoundary) + { + value = Regex.Escape(value); + builder.Append(value); + if (untilNextBoundary) + { + builder.Append(NextBoundary); + } + } + + /// + private static string[] SplitText(string text, string regex, bool caseSensitive) + { + return Regex.Split(text, regex, caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase, _regExMatchTimeout); + } + + /// + private static Memory 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); + } +} diff --git a/tests/Core/Components/Base/ComponentBaseTests.cs b/tests/Core/Components/Base/ComponentBaseTests.cs index 91802c68e8..f74a5fe016 100644 --- a/tests/Core/Components/Base/ComponentBaseTests.cs +++ b/tests/Core/Components/Base/ComponentBaseTests.cs @@ -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")}, }; /// diff --git a/tests/Core/Components/Highlighter/FluentHighlighterTests.razor b/tests/Core/Components/Highlighter/FluentHighlighterTests.razor new file mode 100644 index 0000000000..a5eb6e1690 --- /dev/null +++ b/tests/Core/Components/Highlighter/FluentHighlighterTests.razor @@ -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(@); + + // 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(@); + + // 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(@); + + // 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(@); + + // 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(@); + + // 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(@); + + // Act + var highlightedText = cut.FindAll("mark"); + + // Assert + Assert.Empty(highlightedText); + Assert.Empty(cut.Markup); + } +}