Merge remote-tracking branch 'origin/master' into trim

This commit is contained in:
Atanas Korchev
2026-06-25 18:38:40 +03:00
13 changed files with 1303 additions and 9 deletions

View File

@@ -117,4 +117,27 @@ public class ChartTests
var (barHovered, _) = instance.FindClosestSeries(linePoint.X, linePoint.Y + 80, 25);
Assert.Same(column, barHovered);
}
[Fact]
public void Chart_Renders_AllowZoom_DataAttribute_And_Updates_On_Runtime_Change()
{
using var ctx = CreateChartContext();
var chart = ctx.RenderComponent<RadzenChart>(parameters => parameters
.AddChildContent<RadzenColumnSeries<MultiAxisItem>>(series => series
.Add(p => p.CategoryProperty, nameof(MultiAxisItem.Month))
.Add(p => p.ValueProperty, nameof(MultiAxisItem.Revenue))
.Add(p => p.Data, TallBarData)));
var root = chart.Find("[data-allow-zoom]");
Assert.Equal("false", root.GetAttribute("data-allow-zoom"));
Assert.Equal("0", root.GetAttribute("data-zoom-start"));
Assert.Equal("1", root.GetAttribute("data-zoom-end"));
chart.SetParametersAndRender(parameters => parameters.Add(p => p.AllowZoom, true));
Assert.Equal("true", chart.Find("[data-allow-zoom]").GetAttribute("data-allow-zoom"));
chart.SetParametersAndRender(parameters => parameters.Add(p => p.AllowZoom, false));
Assert.Equal("false", chart.Find("[data-allow-zoom]").GetAttribute("data-allow-zoom"));
}
}

View File

@@ -1,10 +1,12 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection;
using Radzen.Blazor;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Xunit;
@@ -490,5 +492,415 @@ namespace Radzen.Blazor.Tests
Assert.Empty(component.FindAll(".rz-chat-date-separator"));
}
// Baseline tests for mention feature backwards compatibility
[Fact]
public void RadzenChat_ShouldRenderNormallyWithoutMentionCharacter()
{
var messages = new List<ChatMessage>
{
new ChatMessage { Content = "Hello @user2", UserId = "user1", Timestamp = DateTime.Now }
};
var users = new List<ChatUser>
{
new ChatUser { Id = "user1", Name = "John" },
new ChatUser { Id = "user2", Name = "Jane" }
};
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.Title, "Test Chat")
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, users)
.Add(p => p.Messages, messages)
);
Assert.Contains("Hello @user2", component.Markup);
}
[Fact]
public void RadzenChat_ShouldNotShowMentionPopupWhenMentionCharacterIsNull()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.Title, "Test Chat")
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, new List<ChatUser>())
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, null)
);
var mentionPopup = component.FindAll(".rz-chat-mention-popup");
Assert.Empty(mentionPopup);
}
[Fact]
public void RadzenChat_ShouldSendMessagesWithoutMentionFeature()
{
var messages = new List<ChatMessage>();
var messageSent = false;
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.Title, "Test Chat")
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, new List<ChatUser>())
.Add(p => p.Messages, messages)
.Add(p => p.MessageSent, EventCallback.Factory.Create<ChatMessage>(this, (msg) =>
{
messageSent = true;
}))
);
Assert.False(messageSent);
}
// Feature tests for mention functionality
[Fact]
public void RadzenChat_ShouldParseMentionFormatCorrectly()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, new List<ChatUser>())
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, '@')
);
var chat = component.Instance;
var text = "Hello @[user-123] how are you?";
var mentions = chat.ParseMentions(text);
Assert.Single(mentions);
Assert.Equal("user-123", mentions[0].userId);
}
[Fact]
public void RadzenChat_ShouldParseMutipleMentionsCorrectly()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, new List<ChatUser>())
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, '@')
);
var chat = component.Instance;
var text = "Hey @[user-123] and @[user-456] check this out!";
var mentions = chat.ParseMentions(text);
Assert.Equal(2, mentions.Count);
Assert.Equal("user-123", mentions[0].userId);
Assert.Equal("user-456", mentions[1].userId);
}
[Fact]
public void RadzenChat_ShouldNotParseMentionsWhenMentionCharacterIsNull()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, new List<ChatUser>())
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, null)
);
var chat = component.Instance;
var text = "Hello @[user-123] how are you?";
var mentions = chat.ParseMentions(text);
Assert.Empty(mentions);
}
[Fact]
public async Task RadzenChat_ShouldSetMentionSearchResults()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, new List<ChatUser>())
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, '@')
);
var chat = component.Instance;
var results = new List<MentionUserContext>
{
new MentionUserContext { UserId = "user-1", UserName = "Alice", IsInChat = true },
new MentionUserContext { UserId = "user-2", UserName = "Bob", IsInChat = false }
};
await chat.SetMentionSearchResults(results);
Assert.Equal(2, results.Count);
}
[Fact]
public void RadzenChat_ShouldHaveMentionPropertiesOptional()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, new List<ChatUser>())
.Add(p => p.Messages, new List<ChatMessage>())
// MentionCharacter not set - should default to null (disabled)
);
var chat = component.Instance;
Assert.Null(chat.MentionCharacter);
}
[Fact]
public void RadzenChat_ShouldAcceptMentionCharacter()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, new List<ChatUser>())
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, '@')
);
var chat = component.Instance;
Assert.Equal('@', chat.MentionCharacter);
}
[Fact]
public void RadzenChat_ShouldAcceptMentionItemTemplate()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, new List<ChatUser>())
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, '@')
.Add(p => p.MentionItemTemplate, (RenderFragment<MentionUserContext>)((context) => builder => {
builder.OpenElement(0, "div");
builder.AddContent(1, context.UserName ?? "");
builder.CloseElement();
}))
);
var chat = component.Instance;
Assert.NotNull(chat.MentionItemTemplate);
}
[Fact]
public void RadzenChat_ShouldAcceptMentionDisplayTemplate()
{
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, new List<ChatUser>())
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, '@')
.Add(p => p.MentionDisplayTemplate, (RenderFragment<string>)((userId) => builder => {
builder.OpenElement(0, "span");
builder.AddContent(1, $"@{userId}");
builder.CloseElement();
}))
);
var chat = component.Instance;
Assert.NotNull(chat.MentionDisplayTemplate);
}
[Fact]
public void RadzenChat_ShouldDisplayMessageWithMentionUsingTemplate()
{
var messages = new List<ChatMessage>
{
new ChatMessage { Content = "Hi @[user-123] how are you?", UserId = "user1", Timestamp = DateTime.Now }
};
var users = new List<ChatUser>
{
new ChatUser { Id = "user1", Name = "John" },
new ChatUser { Id = "user-123", Name = "Alice" }
};
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, users)
.Add(p => p.Messages, messages)
.Add(p => p.MentionCharacter, '@')
.Add(p => p.MentionDisplayTemplate, (RenderFragment<string>)((userId) => builder => {
builder.OpenElement(0, "span");
builder.AddAttribute(1, "class", "rz-mention-badge");
builder.AddContent(2, $"@{userId}");
builder.CloseElement();
}))
);
var mentionBadge = component.FindAll(".rz-mention-badge");
Assert.NotEmpty(mentionBadge);
}
[Fact]
public async Task RadzenChat_ShouldRenderSelectedMentionWithUserNameInInput()
{
var users = new List<ChatUser>
{
new ChatUser { Id = "user1", Name = "John" },
new ChatUser { Id = "user2", Name = "Jane Smith" }
};
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, users)
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, '@')
.Add(p => p.MentionDisplayTemplate, (RenderFragment<string>)((userId) => builder =>
{
builder.OpenElement(0, "span");
builder.AddContent(1, $"@{users.First(u => u.Id == userId).Name}");
builder.CloseElement();
}))
);
SetPrivateProperty(component.Instance, "CurrentInput", "@ja");
SetPrivateField(component.Instance, "mentionStartPosition", 0);
SetPrivateField(component.Instance, "mentionSearchText", "ja");
await InvokePrivateAsync(component.Instance, "InsertMention", new MentionUserContext { UserId = "user2", UserName = "Jane Smith", IsInChat = true });
component.WaitForAssertion(() =>
{
var updatedTextarea = component.Find(".rz-chat-textarea");
Assert.Equal("@Jane Smith", updatedTextarea.GetAttribute("value"));
});
}
[Fact]
public async Task RadzenChat_ShouldSendCanonicalMentionFormatWhenInputShowsUserName()
{
var users = new List<ChatUser>
{
new ChatUser { Id = "user1", Name = "John" },
new ChatUser { Id = "user2", Name = "Jane Smith" }
};
ChatMessage sentMessage = null;
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, users)
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, '@')
.Add(p => p.MentionDisplayTemplate, (RenderFragment<string>)((userId) => builder =>
{
builder.OpenElement(0, "span");
builder.AddContent(1, $"@{users.First(u => u.Id == userId).Name}");
builder.CloseElement();
}))
.Add(p => p.MessageSent, EventCallback.Factory.Create<ChatMessage>(this, message => sentMessage = message))
);
SetPrivateProperty(component.Instance, "CurrentInput", "Hi @ja");
SetPrivateField(component.Instance, "mentionStartPosition", 3);
SetPrivateField(component.Instance, "mentionSearchText", "ja");
await InvokePrivateAsync(component.Instance, "InsertMention", new MentionUserContext { UserId = "user2", UserName = "Jane Smith", IsInChat = true });
component.WaitForAssertion(() =>
{
var updatedTextarea = component.Find(".rz-chat-textarea");
Assert.Equal("Hi @Jane Smith", updatedTextarea.GetAttribute("value"));
});
var textarea = component.Find(".rz-chat-textarea");
textarea.KeyDown(new KeyboardEventArgs { Key = "Enter" });
component.WaitForAssertion(() =>
{
Assert.NotNull(sentMessage);
Assert.Equal("Hi @[user2]", sentMessage!.Content);
});
}
[Fact]
public async Task RadzenChat_ShouldDeleteSelectedMentionInOneStep()
{
var users = new List<ChatUser>
{
new ChatUser { Id = "user1", Name = "John" },
new ChatUser { Id = "user2", Name = "Jane Smith" }
};
using var ctx = new TestContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
ctx.JSInterop.Setup<int[]>("Radzen.getSelectionRange", _ => true).SetResult(new[] { 11, 11 });
var component = ctx.RenderComponent<RadzenChat>(parameters => parameters
.Add(p => p.CurrentUserId, "user1")
.Add(p => p.Users, users)
.Add(p => p.Messages, new List<ChatMessage>())
.Add(p => p.MentionCharacter, '@')
.Add(p => p.MentionDisplayTemplate, (RenderFragment<string>)((userId) => builder =>
{
builder.OpenElement(0, "span");
builder.AddContent(1, $"@{users.First(u => u.Id == userId).Name}");
builder.CloseElement();
}))
);
SetPrivateProperty(component.Instance, "CurrentInput", "@ja");
SetPrivateField(component.Instance, "mentionStartPosition", 0);
SetPrivateField(component.Instance, "mentionSearchText", "ja");
await InvokePrivateAsync(component.Instance, "InsertMention", new MentionUserContext { UserId = "user2", UserName = "Jane Smith", IsInChat = true });
component.WaitForAssertion(() =>
{
var updatedTextarea = component.Find(".rz-chat-textarea");
Assert.Equal("@Jane Smith", updatedTextarea.GetAttribute("value"));
});
var textarea = component.Find(".rz-chat-textarea");
textarea.KeyDown(new KeyboardEventArgs { Key = "Backspace" });
component.WaitForAssertion(() =>
{
var updatedTextarea = component.Find(".rz-chat-textarea");
Assert.Equal(string.Empty, updatedTextarea.GetAttribute("value"));
});
}
static void SetPrivateField(object instance, string fieldName, object value)
{
var field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
field.SetValue(instance, value);
}
static void SetPrivateProperty(object instance, string propertyName, object value)
{
var property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.NonPublic);
property.SetValue(instance, value);
}
static async Task InvokePrivateAsync(object instance, string methodName, params object[] args)
{
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
var task = (Task)method.Invoke(instance, args);
await task;
}
}
}

View File

@@ -1,3 +1,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using Bunit;
using Xunit;
using static Radzen.Blazor.Tests.ChartTestHelper;
@@ -6,6 +10,80 @@ namespace Radzen.Blazor.Tests
{
public class FunnelSeriesTests
{
private static readonly DataItem[] SkewedData = new[]
{
new DataItem { Category = "Visitors", Value = 179 },
new DataItem { Category = "Leads", Value = 31 },
new DataItem { Category = "Prospects", Value = 15 },
new DataItem { Category = "Negotiations", Value = 9 },
};
private static List<(double Y, double Width)> ParseEdgeWidths(string markup)
{
var edges = new List<(double, double)>();
foreach (Match match in Regex.Matches(markup, "--rz-segment-order:.*?d=\"M ([^\"]+)\"", RegexOptions.Singleline))
{
var numbers = Regex.Matches(match.Groups[1].Value, "-?[0-9]+(\\.[0-9]+)?")
.Select(m => double.Parse(m.Value, CultureInfo.InvariantCulture))
.ToArray();
if (numbers.Length != 8)
{
continue;
}
var topWidth = System.Math.Abs(numbers[2] - numbers[0]);
var bottomWidth = System.Math.Abs(numbers[4] - numbers[6]);
edges.Add((numbers[1], topWidth));
edges.Add((numbers[5], bottomWidth));
}
return edges.OrderBy(e => e.Item1).ToList();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void FunnelSeries_SkewedValues_OutlineDoesNotFlareBack(bool inverted)
{
using var ctx = CreateChartContext();
var chart = ctx.RenderComponent<RadzenChart>(p => p
.AddChildContent<RadzenFunnelSeries<DataItem>>(s => s
.Add(x => x.CategoryProperty, nameof(DataItem.Category))
.Add(x => x.ValueProperty, nameof(DataItem.Value))
.Add(x => x.Inverted, inverted)
.Add(x => x.Data, SkewedData)));
var edges = ParseEdgeWidths(chart.Markup);
Assert.NotEmpty(edges);
var widths = edges.Select(e => e.Width).ToList();
const double tolerance = 0.01;
var nonDecreasing = true;
var nonIncreasing = true;
for (var i = 1; i < widths.Count; i++)
{
if (widths[i] < widths[i - 1] - tolerance)
{
nonDecreasing = false;
}
if (widths[i] > widths[i - 1] + tolerance)
{
nonIncreasing = false;
}
}
Assert.True(nonDecreasing || nonIncreasing,
$"Funnel outline reverses direction (flares back out): [{string.Join(", ", widths)}].");
}
[Fact]
public void FunnelSeries_Renders_FunnelClass()
{

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace Radzen.Blazor;
/// <summary>
/// Supplies information about a mention search event in the RadzenChat component.
/// </summary>
public class MentionSearchArgs
{
/// <summary>
/// Gets or sets the search filter text (text typed after the mention character).
/// </summary>
public string? Filter { get; set; }
/// <summary>
/// Gets or sets how many items to skip. Related to pagination. Usually used with the <see cref="System.Linq.Enumerable.Skip{TSource}(IEnumerable{TSource}, int)"/> LINQ method.
/// </summary>
public int? Skip { get; set; }
/// <summary>
/// Gets or sets how many items to take. Related to pagination and page size. Usually used with the <see cref="System.Linq.Enumerable.Take{TSource}(IEnumerable{TSource}, int)"/> LINQ method.
/// </summary>
public int? Top { get; set; }
}

View File

@@ -0,0 +1,24 @@
using System;
namespace Radzen.Blazor;
/// <summary>
/// Represents context information for a user in a mention search result in the RadzenChat component.
/// </summary>
public class MentionUserContext
{
/// <summary>
/// Gets or sets the unique identifier of the user.
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// Gets or sets the display name of the user.
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// Gets or sets whether the user is currently in the chat.
/// </summary>
public bool IsInChat { get; set; }
}

View File

@@ -13,7 +13,7 @@
<IsPackable>true</IsPackable>
<PackageId>Radzen.Blazor</PackageId>
<Product>Radzen.Blazor</Product>
<Version>11.0.3</Version>
<Version>11.0.4</Version>
<Copyright>Radzen Ltd.</Copyright>
<Authors>Radzen Ltd.</Authors>
<Description>Radzen Blazor is the most sophisticated free UI component library for Blazor, featuring 110+ native components including DataGrid, Scheduler, Charts, and advanced theming with full support for Material Design and Fluent UI.</Description>

View File

@@ -8,7 +8,7 @@
<CascadingValue Value="@this">
@ChildContent
</CascadingValue>
<div @ref="Element" @attributes="@Attributes" class="@GetCssClass()" style="@GetChartStyle()" id="@GetId()">
<div @ref="Element" @attributes="@Attributes" class="@GetCssClass()" style="@GetChartStyle()" id="@GetId()" data-allow-zoom="@(AllowZoom ? "true" : "false")" data-zoom-start="@ZoomStart.ToInvariantString()" data-zoom-end="@ZoomEnd.ToInvariantString()">
@if (Width.HasValue && Height.HasValue)
{
<CascadingValue Value="@this">

View File

@@ -126,7 +126,15 @@
}
else
{
<RadzenMarkdown Text=@message.Content />
@if (MentionCharacter.HasValue && MentionDisplayTemplate != null && message.Content.Contains($"{MentionCharacter}["))
{
// Parse and render mentions
@RenderMessageWithMentions(message.Content)
}
else
{
<RadzenMarkdown Text=@message.Content />
}
}
</div>
@@ -184,6 +192,27 @@
Title="Send message"
class="rz-chat-send-btn" />
</div>
@if (isMentionPopupOpen && MentionCharacter.HasValue && mentionSearchResults.Count > 0)
{
<div class="rz-chat-mention-popup">
@foreach (var (user, index) in mentionSearchResults.Select((u, i) => (u, i)))
{
var isSelected = index == selectedMentionIndex;
<div class="rz-chat-mention-item @(isSelected ? "rz-chat-mention-item-selected" : "")"
@onclick="@((MouseEventArgs e) => OnMentionItemClick(user))">
@if (MentionItemTemplate != null)
{
@MentionItemTemplate(user)
}
else
{
<span>@user.UserName</span>
}
</div>
}
</div>
}
</div>
</div>
}

View File

@@ -6,6 +6,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -125,6 +126,25 @@ namespace Radzen.Blazor
private bool currentUserIsTyping;
private CancellationTokenSource? typingCts;
// Mention-related fields
private List<MentionUserContext> mentionSearchResults = new();
private bool isMentionPopupOpen;
private string mentionSearchText = string.Empty;
private int selectedMentionIndex = -1;
private int mentionStartPosition = -1;
private CancellationTokenSource? mentionSearchCts;
private readonly List<MentionInputSegment> mentionInputSegments = new();
private sealed class MentionInputSegment
{
public int Start { get; set; }
public int Length { get; set; }
public string UserId { get; set; } = string.Empty;
public string DisplayText { get; set; } = string.Empty;
public int End => Start + Length;
}
/// <summary>
/// Gets or sets the message template.
/// </summary>
@@ -353,6 +373,65 @@ namespace Radzen.Blazor
[Parameter]
public EventCallback<ChatUser> UserRemoved { get; set; }
/// <summary>
/// Gets or sets the character that triggers the mention search popup (e.g., '@'). When null, mention feature is disabled.
/// </summary>
[Parameter]
public char? MentionCharacter { get; set; }
/// <summary>
/// Event callback that is invoked when a mention search is triggered.
/// The callback receives a <see cref="MentionSearchArgs"/> with the search filter and pagination info.
/// The app should populate the search results which will be displayed in the mention popup.
/// </summary>
[Parameter]
public EventCallback<MentionSearchArgs> MentionSearch { get; set; }
/// <summary>
/// Gets or sets the template for rendering individual items in the mention search popup.
/// Receives a <see cref="MentionUserContext"/> containing the user ID, name, and chat status.
/// </summary>
[Parameter]
public RenderFragment<MentionUserContext>? MentionItemTemplate { get; set; }
/// <summary>
/// Gets or sets the template for rendering a stored mention in the chat message.
/// Receives the user ID string (from @[userid] format) and should render how the mention is displayed (e.g., as a badge).
/// </summary>
[Parameter]
public RenderFragment<string>? MentionDisplayTemplate { get; set; }
/// <summary>
/// Gets or sets the maximum number of mention search results to display in the popup. Defaults to 10.
/// </summary>
[Parameter]
public int MentionMaxResults { get; set; } = 10;
/// <summary>
/// Gets or sets the event callback that stores mention search results from the MentionSearch callback.
/// </summary>
[Parameter]
public EventCallback<IEnumerable<MentionUserContext>> MentionSearchResultsChanged { get; set; }
/// <summary>
/// Updates the mention search results and refreshes the popup.
/// Call this from within your MentionSearch event callback handler.
/// </summary>
/// <param name="results">The search results to display.</param>
public async Task SetMentionSearchResults(IEnumerable<MentionUserContext>? results)
{
mentionSearchResults = results?.ToList() ?? new();
selectedMentionIndex = mentionSearchResults.Count > 0 ? 0 : -1;
isMentionPopupOpen = mentionSearchResults.Count > 0;
if (MentionSearchResultsChanged.HasDelegate)
{
await MentionSearchResultsChanged.InvokeAsync(mentionSearchResults);
}
await InvokeAsync(StateHasChanged);
}
/// <summary>
/// Gets the current list of messages.
/// </summary>
@@ -468,6 +547,7 @@ namespace Radzen.Blazor
// Clear input
CurrentInput = string.Empty;
mentionInputSegments.Clear();
await SetCurrentUserTyping(false);
await InvokeAsync(StateHasChanged);
}
@@ -557,8 +637,11 @@ namespace Radzen.Blazor
private async Task OnInput(ChangeEventArgs e)
{
var previousInput = CurrentInput;
CurrentInput = e.Value?.ToString() ?? "";
ReconcileMentionInputSegments(previousInput, CurrentInput);
await NotifyCurrentUserTyping();
await DetectMentionTrigger(CurrentInput);
await InvokeAsync(StateHasChanged);
}
@@ -627,6 +710,50 @@ namespace Radzen.Blazor
private async Task OnKeyDown(KeyboardEventArgs e)
{
// Handle mention popup navigation
if (isMentionPopupOpen && MentionCharacter.HasValue)
{
if (e.Key == "ArrowDown")
{
selectedMentionIndex = Math.Min(selectedMentionIndex + 1, mentionSearchResults.Count - 1);
preventDefault = true;
await InvokeAsync(StateHasChanged);
return;
}
else if (e.Key == "ArrowUp")
{
selectedMentionIndex = Math.Max(selectedMentionIndex - 1, 0);
preventDefault = true;
await InvokeAsync(StateHasChanged);
return;
}
else if (e.Key == "Enter")
{
if (selectedMentionIndex >= 0 && selectedMentionIndex < mentionSearchResults.Count)
{
await InsertMention(mentionSearchResults[selectedMentionIndex]);
preventDefault = true;
return;
}
}
else if (e.Key == "Escape")
{
await CloseMentionPopup();
preventDefault = true;
return;
}
}
if (MentionCharacter.HasValue && mentionInputSegments.Count > 0 && (e.Key == "Backspace" || e.Key == "Delete"))
{
if (await HandleMentionDeletion(e.Key))
{
preventDefault = true;
return;
}
}
// Handle normal message sending
if (e.Key == "Enter" && !e.ShiftKey && JSRuntime != null)
{
await JSRuntime.InvokeAsync<string>("Radzen.setInputValue", inputElement, "");
@@ -639,12 +766,396 @@ namespace Radzen.Blazor
private async Task OnSendMessage()
{
if (!string.IsNullOrWhiteSpace(CurrentInput))
var content = BuildStoredMessageInput();
if (!string.IsNullOrWhiteSpace(content))
{
await SendMessage(CurrentInput);
await SendMessage(content);
}
}
private async Task DetectMentionTrigger(string input)
{
if (!MentionCharacter.HasValue || MentionSearch.HasDelegate == false)
{
return;
}
var char_code = MentionCharacter.Value;
var trimmedInput = input.TrimEnd();
// Check if mention character appears at the start or after whitespace
int mentionPos = -1;
for (int i = trimmedInput.Length - 1; i >= 0; i--)
{
if (trimmedInput[i] == char_code)
{
// Check if it's at the start or after whitespace
if (i == 0 || char.IsWhiteSpace(trimmedInput[i - 1]))
{
mentionPos = i;
}
break;
}
else if (char.IsWhiteSpace(trimmedInput[i]))
{
break;
}
}
if (mentionPos >= 0)
{
// Extract search text after the mention character
var searchText = trimmedInput.Substring(mentionPos + 1);
// Check if this is a continuous mention (no spaces in search text)
if (!searchText.Contains(' ', StringComparison.Ordinal))
{
mentionSearchText = searchText;
mentionStartPosition = mentionPos;
await PerformMentionSearch(searchText);
return;
}
}
await CloseMentionPopup();
}
private async Task PerformMentionSearch(string filter)
{
if (!MentionSearch.HasDelegate)
{
return;
}
mentionSearchCts?.Cancel();
mentionSearchCts?.Dispose();
mentionSearchCts = new CancellationTokenSource();
try
{
var searchArgs = new MentionSearchArgs
{
Filter = filter,
Skip = 0,
Top = MentionMaxResults
};
await MentionSearch.InvokeAsync(searchArgs);
isMentionPopupOpen = true;
selectedMentionIndex = 0;
if (MentionSearchResultsChanged.HasDelegate)
{
await MentionSearchResultsChanged.InvokeAsync(mentionSearchResults);
}
await InvokeAsync(StateHasChanged);
}
catch (OperationCanceledException)
{
// Search was cancelled
}
}
private async Task InsertMention(MentionUserContext user)
{
if (user?.UserId == null)
{
return;
}
// Replace the search text with the mention
var beforeMention = CurrentInput.Substring(0, mentionStartPosition);
var afterMention = CurrentInput.Substring(mentionStartPosition + 1 + mentionSearchText.Length);
var displayName = ResolveMentionDisplayName(user);
var displayMention = $"{MentionCharacter}{displayName}";
var replacedLength = 1 + mentionSearchText.Length;
ShiftMentionSegmentsAfterEdit(mentionStartPosition, replacedLength, displayMention.Length);
mentionInputSegments.Add(new MentionInputSegment
{
Start = mentionStartPosition,
Length = displayMention.Length,
UserId = user.UserId,
DisplayText = displayMention
});
CurrentInput = beforeMention + displayMention + afterMention;
await CloseMentionPopup();
await InvokeAsync(StateHasChanged);
}
private async Task CloseMentionPopup()
{
isMentionPopupOpen = false;
mentionSearchResults.Clear();
mentionSearchText = string.Empty;
selectedMentionIndex = -1;
mentionStartPosition = -1;
mentionSearchCts?.Cancel();
mentionSearchCts?.Dispose();
mentionSearchCts = null;
if (MentionSearchResultsChanged.HasDelegate)
{
await MentionSearchResultsChanged.InvokeAsync(mentionSearchResults);
}
await InvokeAsync(StateHasChanged);
}
private async Task OnMentionItemClick(MentionUserContext user)
{
await InsertMention(user);
}
private string BuildStoredMessageInput()
{
if (mentionInputSegments.Count == 0 || !MentionCharacter.HasValue)
{
return CurrentInput;
}
var orderedSegments = mentionInputSegments.OrderBy(segment => segment.Start).ToList();
var builder = new StringBuilder();
var currentIndex = 0;
foreach (var segment in orderedSegments)
{
if (segment.Start < currentIndex || segment.End > CurrentInput.Length)
{
continue;
}
var segmentText = CurrentInput.Substring(segment.Start, segment.Length);
if (!string.Equals(segmentText, segment.DisplayText, StringComparison.Ordinal))
{
continue;
}
builder.Append(CurrentInput.AsSpan(currentIndex, segment.Start - currentIndex));
builder.Append(MentionCharacter.Value);
builder.Append('[');
builder.Append(segment.UserId);
builder.Append(']');
currentIndex = segment.End;
}
if (currentIndex < CurrentInput.Length)
{
builder.Append(CurrentInput.AsSpan(currentIndex));
}
return builder.ToString();
}
private string ResolveMentionDisplayName(MentionUserContext user)
{
if (!string.IsNullOrWhiteSpace(user.UserName))
{
return user.UserName;
}
var userId = user.UserId ?? string.Empty;
var chatUser = GetUser(userId);
if (!string.IsNullOrWhiteSpace(chatUser?.Name))
{
return chatUser.Name;
}
return userId;
}
private void ReconcileMentionInputSegments(string previousInput, string nextInput)
{
if (mentionInputSegments.Count == 0 || string.Equals(previousInput, nextInput, StringComparison.Ordinal))
{
return;
}
var commonPrefixLength = 0;
var maxPrefixLength = Math.Min(previousInput.Length, nextInput.Length);
while (commonPrefixLength < maxPrefixLength && previousInput[commonPrefixLength] == nextInput[commonPrefixLength])
{
commonPrefixLength++;
}
var previousSuffixIndex = previousInput.Length;
var nextSuffixIndex = nextInput.Length;
while (previousSuffixIndex > commonPrefixLength &&
nextSuffixIndex > commonPrefixLength &&
previousInput[previousSuffixIndex - 1] == nextInput[nextSuffixIndex - 1])
{
previousSuffixIndex--;
nextSuffixIndex--;
}
var previousChangedEnd = previousSuffixIndex;
var lengthDelta = nextInput.Length - previousInput.Length;
var updatedSegments = new List<MentionInputSegment>(mentionInputSegments.Count);
foreach (var segment in mentionInputSegments.OrderBy(segment => segment.Start))
{
if (segment.End <= commonPrefixLength)
{
updatedSegments.Add(segment);
continue;
}
if (segment.Start >= previousChangedEnd)
{
segment.Start += lengthDelta;
updatedSegments.Add(segment);
continue;
}
}
mentionInputSegments.Clear();
mentionInputSegments.AddRange(updatedSegments);
}
private void ShiftMentionSegmentsAfterEdit(int editStart, int replacedLength, int insertedLength)
{
if (mentionInputSegments.Count == 0)
{
return;
}
var editEnd = editStart + replacedLength;
var delta = insertedLength - replacedLength;
mentionInputSegments.RemoveAll(segment => segment.Start < editEnd && segment.End > editStart);
foreach (var segment in mentionInputSegments.Where(segment => segment.Start >= editEnd))
{
segment.Start += delta;
}
}
private async Task<bool> HandleMentionDeletion(string key)
{
if (JSRuntime == null)
{
return false;
}
var selection = await JSRuntime.InvokeAsync<int[]?>("Radzen.getSelectionRange", inputElement);
if (selection == null || selection.Length < 2)
{
return false;
}
var selectionStart = selection[0];
var selectionEnd = selection[1];
MentionInputSegment? segmentToRemove = null;
if (selectionStart != selectionEnd)
{
segmentToRemove = mentionInputSegments.FirstOrDefault(segment => segment.Start < selectionEnd && segment.End > selectionStart);
}
else
{
var targetIndex = key == "Backspace" ? selectionStart - 1 : selectionStart;
if (targetIndex < 0)
{
return false;
}
segmentToRemove = mentionInputSegments.FirstOrDefault(segment => segment.Start <= targetIndex && segment.End > targetIndex);
}
if (segmentToRemove == null)
{
return false;
}
CurrentInput = CurrentInput.Remove(segmentToRemove.Start, segmentToRemove.Length);
mentionInputSegments.Remove(segmentToRemove);
foreach (var segment in mentionInputSegments.Where(segment => segment.Start > segmentToRemove.Start))
{
segment.Start -= segmentToRemove.Length;
}
await JSRuntime.InvokeVoidAsync("Radzen.setInputValue", inputElement, CurrentInput);
await JSRuntime.InvokeVoidAsync("Radzen.setSelectionRange", inputElement, segmentToRemove.Start, segmentToRemove.Start);
await DetectMentionTrigger(CurrentInput);
await InvokeAsync(StateHasChanged);
return true;
}
internal List<(int start, int end, string userId)> ParseMentions(string text)
{
var mentions = new List<(int, int, string)>();
if (string.IsNullOrEmpty(text) || !MentionCharacter.HasValue)
{
return mentions;
}
var pattern = $@"\{MentionCharacter}\[([^\]]+)\]";
var regex = new System.Text.RegularExpressions.Regex(pattern);
foreach (System.Text.RegularExpressions.Match match in regex.Matches(text))
{
var userId = match.Groups[1].Value;
mentions.Add((match.Index, match.Length, userId));
}
return mentions;
}
private RenderFragment RenderMessageWithMentions(string text)
{
return builder =>
{
if (string.IsNullOrEmpty(text) || !MentionCharacter.HasValue || MentionDisplayTemplate == null)
{
builder.AddContent(0, new MarkupString($"<p>{System.Net.WebUtility.HtmlEncode(text)}</p>"));
return;
}
var mentions = ParseMentions(text);
if (mentions.Count == 0)
{
builder.AddContent(0, new MarkupString($"<p>{System.Net.WebUtility.HtmlEncode(text)}</p>"));
return;
}
int lastIndex = 0;
int contentIndex = 0;
builder.OpenElement(contentIndex++, "p");
foreach (var mention in mentions)
{
// Add text before mention
if (mention.start > lastIndex)
{
var textBefore = text.Substring(lastIndex, mention.start - lastIndex);
builder.AddContent(contentIndex++, textBefore);
}
// Add the mention using the template
var userId = mention.userId;
builder.AddContent(contentIndex++, MentionDisplayTemplate(userId));
lastIndex = mention.start + mention.end;
}
// Add remaining text after last mention
if (lastIndex < text.Length)
{
var textAfter = text.Substring(lastIndex);
builder.AddContent(contentIndex++, textAfter);
}
builder.CloseElement();
};
}
private async Task OnClearChat()
{
await ClearChat();
@@ -730,6 +1241,10 @@ namespace Radzen.Blazor
typingCts?.Cancel();
typingCts?.Dispose();
typingCts = null;
mentionSearchCts?.Cancel();
mentionSearchCts?.Dispose();
mentionSearchCts = null;
}
catch
{

View File

@@ -325,16 +325,16 @@ namespace Radzen.Blazor
topWidth = valueWidth;
bottomWidth = index < count - 1
? AvailableWidth * (Value(orderedItems[index + 1]) / maxValue)
: AvailableWidth * 0.15;
: Math.Min(AvailableWidth * 0.15, valueWidth);
}
else
{
// Default: wide at visual top, narrow at visual bottom.
// In polar coords, index 0 = visual bottom → narrow the first segment's "top" (visual bottom edge).
topWidth = index == 0 ? AvailableWidth * 0.15 : valueWidth;
bottomWidth = index < count - 1
? AvailableWidth * (Value(orderedItems[index + 1]) / maxValue)
: valueWidth;
topWidth = index == 0 ? Math.Min(AvailableWidth * 0.15, bottomWidth) : valueWidth;
}
var topY = this.TopY + index * segHeight;

View File

@@ -3373,11 +3373,14 @@ window.Radzen = {
};
ref.wheelHandler = function (e) {
if (!inside) return;
if (!inside || ref.dataset.allowZoom !== 'true') return;
var delta = e.deltaY > 0 ? 1 : -1;
var range = parseFloat(ref.dataset.zoomEnd) - parseFloat(ref.dataset.zoomStart);
if (isNaN(range)) range = 1;
if (delta < 0 ? range <= 0.01 + 1e-6 : range >= 1 - 1e-6) return;
e.preventDefault();
var rect = ref.getBoundingClientRect();
var x = e.clientX - rect.left;
var delta = e.deltaY > 0 ? 1 : -1;
try { suppressDisposed(instance.invokeMethodAsync('OnWheel', x, delta)); } catch {}
};

View File

@@ -0,0 +1,175 @@
@using Radzen
@using Radzen.Blazor
@inherits ComponentBase
<RadzenStack class="rz-p-0 rz-p-md-12">
<RadzenCard class="rz-p-4" Variant="Variant.Outlined">
<RadzenStack Orientation="Orientation.Vertical" Gap="0.5rem">
<RadzenLabel Text="Mention Users in Chat" />
</RadzenStack>
</RadzenCard>
<RadzenChat @ref="basicChat"
CurrentUserId="user1"
Users="@users"
Messages="@messages"
MessagesChanged="@OnMessagesChanged"
Placeholder="@PlaceholderText"
Style="height: 500px;"
ShowTypingIndicator="true"
MessageAdded="@OnMessageAdded"
MessageSent="@OnMessageSent"
ChatCleared="@OnChatCleared"
TypingChanged="@OnTypingChanged"
MentionCharacter="@MentionCharacter"
MentionMaxResults="5"
MentionSearch="@LoadMentions"
MentionItemTemplate="@MentionItemTemplate"
MentionDisplayTemplate="@MentionDisplayTemplate">
<TitleContent>
<RadzenBadge>Chat with Mentions</RadzenBadge>
</TitleContent>
</RadzenChat>
</RadzenStack>
<EventConsole @ref="console" Style="min-height: 230px;" />
@code {
RadzenChat basicChat;
EventConsole console;
private List<ChatUser> users = new();
private List<ChatMessage> messages = new();
private const char MentionCharacter = '@';
private const string PlaceholderText = "Type your message... (try typing @ to mention someone)";
protected override void OnInitialized()
{
// Initialize users
users.AddRange(new[]
{
new ChatUser { Id = "user1", Name = "John Doe", Color = "#1976d2" },
new ChatUser { Id = "user2", Name = "Jane Smith", Color = "#388e3c" },
new ChatUser { Id = "user3", Name = "Bob Johnson", Color = "#f57c00" }
});
// Add some sample messages
messages.AddRange(new[]
{
new ChatMessage { Content = "Welcome to the team chat! 👋", UserId = "user1", Timestamp = DateTime.Now.AddMinutes(-30) },
new ChatMessage { Content = "Thanks John! Looking forward to working together.", UserId = "user2", Timestamp = DateTime.Now.AddMinutes(-29) },
new ChatMessage { Content = "Same here! Let's make this project amazing! 🚀", UserId = "user3", Timestamp = DateTime.Now.AddMinutes(-28) }
});
}
// Template for rendering individual mention search results
private RenderFragment<MentionUserContext> MentionItemTemplate => (context) => builder =>
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "rz-mention-item");
builder.AddAttribute(2, "style", "display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem;");
// User avatar/initials
var user = users.FirstOrDefault(u => u.Id == context.UserId);
builder.OpenElement(3, "div");
builder.AddAttribute(4, "style", $"width: 24px; height: 24px; border-radius: 50%; background: {user?.Color ?? "#ccc"}; display: flex; align-items: center; justify-content: center; color: white; font-size: 12px; font-weight: bold;");
builder.AddContent(5, user?.GetInitials() ?? "?");
builder.CloseElement();
// User name
builder.OpenElement(6, "span");
builder.AddContent(7, context.UserName ?? "");
builder.CloseElement();
// Online indicator
if (context.IsInChat)
{
builder.OpenElement(8, "span");
builder.AddAttribute(9, "style", "margin-left: auto; width: 8px; height: 8px; border-radius: 50%; background: #4caf50;");
builder.CloseElement();
}
builder.CloseElement();
};
// Template for rendering stored mentions in chat messages
private RenderFragment<string> MentionDisplayTemplate => (userId) => builder =>
{
var mentionedUser = users.FirstOrDefault(u => u.Id == userId);
builder.OpenElement(0, "span");
builder.AddAttribute(1, "class", "rz-mention-badge");
builder.AddAttribute(2, "style", $"background: {mentionedUser?.Color ?? "#ccc"}20; color: {mentionedUser?.Color ?? "#ccc"}; padding: 0.2rem 0.4rem; border-radius: 4px; font-weight: 500;");
builder.AddAttribute(3, "title", mentionedUser?.Name ?? userId);
builder.AddContent(4, $"@{mentionedUser?.Name ?? userId}");
builder.CloseElement();
};
async Task LoadMentions(MentionSearchArgs args)
{
// Simulate a small delay for network request
await Task.Delay(100);
// Filter users based on search text
var searchText = args.Filter ?? string.Empty;
var filteredUsers = users
.Where(u => u.Id != "user1") // Exclude current user
.Where(u => u.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase))
.Skip(args.Skip ?? 0)
.Take(args.Top ?? 5)
.Select(u => new MentionUserContext
{
UserId = u.Id,
UserName = u.Name,
IsInChat = true
})
.ToList();
// Update search results in the chat component
await basicChat?.SetMentionSearchResults(filteredUsers);
console.Log($"Mention search: '{searchText}' - Found {filteredUsers.Count} results", AlertStyle.Info);
}
void OnMessageAdded(ChatMessage message)
{
var participant = users.FirstOrDefault(p => p.Id == message.UserId);
var participantName = participant?.Name ?? "Unknown";
var contentPreview = message.Content.Substring(0, Math.Min(50, message.Content.Length));
console.Log($"Message added: {participantName} - {contentPreview}...",
message.UserId == "user1" ? AlertStyle.Info : AlertStyle.Success);
}
void OnMessageSent(ChatMessage message)
{
console.Log($"Message sent: {message.Content}", AlertStyle.Info);
}
void OnMessagesChanged(IEnumerable<ChatMessage> newMessages)
{
messages = newMessages.ToList();
StateHasChanged();
}
void OnChatCleared()
{
console.Log("Chat cleared", AlertStyle.Warning);
}
async Task SimulateJaneTyping()
{
if (basicChat == null)
{
return;
}
await basicChat.SetUserTyping("user2", true);
await Task.Delay(1500);
await basicChat.SetUserTyping("user2", false);
}
void OnTypingChanged(ChatTypingEventArgs args)
{
// In a real app you would broadcast this via SignalR to other clients.
console.Log($"TypingChanged: {args.UserId} => {args.IsTyping}", AlertStyle.Info);
}
}

View File

@@ -76,3 +76,13 @@
<RadzenExample ComponentName="Chat" Example="ChatTitle">
<ChatTitle />
</RadzenExample>
<RadzenText Anchor="chat#mention" TextStyle="TextStyle.DisplayH5" TagName="TagName.H2" class="rt-pt-12">
Mention Users
</RadzenText>
<RadzenText TextStyle="TextStyle.Body1" class="rz-mb-8">
Use a Mention Character to define on which character a callback should be triggered and display a search popup.
</RadzenText>
<RadzenExample ComponentName="Chat" Example="ChatMention">
<ChatMention />
</RadzenExample>