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
104 changes: 104 additions & 0 deletions NET10/BlazorInteractiveServer/Components/Demo/NewComponentsDemo.razor
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,57 @@
}
</ChildContent>
<Footer>
@if (_messages.Count == 1)
{
<PromptSuggestions Suggestions="_suggestions" OnSelect="SendAsync" Disabled="_thinking" Class="mb-3" />
}
<ChatInput @bind-Value="_draft" OnSubmit="SendAsync" IsLoading="_thinking" />
</Footer>
</Chat>
</section>

<section class="border border-border rounded-lg p-6 bg-card mt-6">
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="text-2xl font-semibold mb-1 text-card-foreground">Reasoning &amp; Tool Call</h2>
<p class="text-sm text-muted-foreground">Reasoning opens while it streams and folds away when done. The tool call goes from running to done.</p>
</div>
<Button Variant="ButtonVariant.Outline" OnClick="RunAgentAsync" Disabled="_agentRunning">@(_agentRunning ? "Running…" : "Run")</Button>
</div>
<div class="space-y-3">
<Reasoning Text="@_reasoning" IsStreaming="_reasoningStreaming" Duration="_reasoningTime" />
<ToolCall Name="search_docs" State="@_toolState" Arguments="@ToolArguments" Result="@_toolResult" />
<ToolCall Name="send_email" State="error" Arguments="@("{ \"to\": \"team@example.com\" }")" Error="SMTP server refused the connection." />
</div>
</section>

<section class="border border-border rounded-lg p-6 bg-card mt-6">
<h2 class="text-2xl font-semibold mb-1 text-card-foreground">Field</h2>
<p class="text-sm text-muted-foreground mb-4">Label, control, description and error as one unit. Submit with the email empty to see validation.</p>
<EditForm Model="_signup" OnValidSubmit="() => _signedUp = true" class="grid max-w-md gap-4">
<DataAnnotationsValidator />
<Field Label="Email" LabelFor="field-email" Required="true" Description="We'll only use it for release notes." For="() => _signup.Email">
<Input id="field-email" @bind-Value="_signup.Email" Placeholder="you@example.com" />
</Field>
<Field Label="Weekly digest" Description="A short summary every Monday." Orientation="horizontal">
<Switch @bind-Checked="_signup.Digest" />
</Field>
<div class="flex items-center gap-3">
<Button Type="submit">Subscribe</Button>
@if (_signedUp)
{
<span class="text-sm text-muted-foreground">Subscribed.</span>
}
</div>
</EditForm>
</section>

<section class="border border-border rounded-lg p-6 bg-card mt-6">
<h2 class="text-2xl font-semibold mb-1 text-card-foreground">JSON Viewer</h2>
<p class="text-sm text-muted-foreground mb-4">Two levels open by default. Click a key to fold it, or copy the whole document.</p>
<JsonViewer Value="_sampleJson" Class="max-h-80" />
</section>

<section class="border border-border rounded-lg p-6 bg-card mt-6">
<h2 class="text-2xl font-semibold mb-4 text-card-foreground">Toggle Group &amp; Button Group</h2>
<div class="space-y-4">
Expand Down Expand Up @@ -156,6 +202,64 @@

private const string SampleImage = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='800' height='500'><defs><linearGradient id='s' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='%2393c5fd'/><stop offset='1' stop-color='%23fde68a'/></linearGradient></defs><rect width='800' height='500' fill='url(%23s)'/><circle cx='620' cy='140' r='60' fill='%23fbbf24'/><path d='M0 380 L200 220 L360 360 L520 240 L800 400 L800 500 L0 500 Z' fill='%2334d399'/><path d='M0 430 L260 300 L460 440 L800 330 L800 500 L0 500 Z' fill='%23059669'/></svg>";

private readonly string[] _suggestions = { "What is ShellUI?", "Show me a dialog example", "How do I add a theme?" };

private string? _reasoning;
private bool _reasoningStreaming;
private TimeSpan? _reasoningTime;
private string _toolState = "pending";
private string? _toolResult;
private bool _agentRunning;
private const string ToolArguments = "{ \"query\": \"dialog focus trap\", \"limit\": 3 }";

private async Task RunAgentAsync()
{
_agentRunning = true;
_reasoning = "";
_reasoningTime = null;
_toolState = "pending";
_toolResult = null;
_reasoningStreaming = true;
var started = DateTime.UtcNow;
foreach (var word in "The user wants to know how dialogs keep focus. I should search the docs for the focus trap, then summarise what Tab does inside an open dialog.".Split(' '))
{
_reasoning += word + " ";
StateHasChanged();
await Task.Delay(90);
}
_reasoningStreaming = false;
_reasoningTime = DateTime.UtcNow - started;
_toolState = "running";
StateHasChanged();
await Task.Delay(1500);
_toolResult = "[\n \"overlays/dialog.md#focus\",\n \"guides/accessibility.md\",\n \"changelog.md#0-3-5\"\n]";
_toolState = "done";
_agentRunning = false;
}

private sealed class SignupModel
{
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required.")]
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Enter a valid email address.")]
public string Email { get; set; } = "";

public bool Digest { get; set; } = true;
}

private readonly SignupModel _signup = new();
private bool _signedUp;

private readonly object _sampleJson = new
{
name = "ShellUI",
version = "0.4.0-alpha.3",
stable = false,
stars = 42,
targets = new[] { "net8.0", "net10.0" },
theme = new { radius = "0.5rem", colors = new { primary = "oklch(0.21 0.006 285.88)", destructive = "oklch(0.58 0.24 28.48)" } },
homepage = (string?)null
};

private async Task SendAsync(string text)
{
_messages.Add(("user", text));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold text-foreground">New in 0.4</h1>
<p class="text-muted-foreground mt-1">Kbd, AspectRatio, ButtonGroup, ToggleGroup, InputGroup, NumberInput, StatCard, Timeline, TreeView, QrCode, ImageViewer and Chat.</p>
<p class="text-muted-foreground mt-1">Kbd, AspectRatio, ButtonGroup, ToggleGroup, InputGroup, NumberInput, StatCard, Timeline, TreeView, QrCode, ImageViewer, Chat, PromptSuggestions, Reasoning, ToolCall, Field and JsonViewer.</p>
</div>
<NewComponentsDemo />
</div>
70 changes: 70 additions & 0 deletions NET10/BlazorInteractiveServer/Components/UI/Field.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
@namespace BlazorInteractiveServer.Components.UI
@using System.Linq.Expressions
@using Microsoft.AspNetCore.Components.Forms

<CascadingValue Value="FieldIdentifier" Name="ShellUIFormField">
<div role="group" class="@Shell.Cn(IsHorizontal ? "flex flex-row items-start justify-between gap-4" : "flex flex-col gap-2", Class)" @attributes="AdditionalAttributes">
@if (IsHorizontal)
{
<div class="flex flex-col gap-1.5">
@if (!string.IsNullOrEmpty(Label))
{
<label for="@LabelFor" class="text-sm font-medium leading-none">
@Label
@if (Required)
{
<span class="ml-0.5 text-destructive" aria-hidden="true">*</span>
}
</label>
}
@if (!string.IsNullOrEmpty(Description))
{
<p class="text-sm text-muted-foreground">@Description</p>
}
<FormMessage Message="@Error" />
</div>
<div class="shrink-0">@ChildContent</div>
}
else
{
@if (!string.IsNullOrEmpty(Label))
{
<label for="@LabelFor" class="text-sm font-medium leading-none">
@Label
@if (Required)
{
<span class="ml-0.5 text-destructive" aria-hidden="true">*</span>
}
</label>
}
@ChildContent
@if (!string.IsNullOrEmpty(Description))
{
<p class="text-sm text-muted-foreground">@Description</p>
}
<FormMessage Message="@Error" />
}
</div>
</CascadingValue>

@code {
[Parameter] public string? Label { get; set; }
// The id of the control inside, so clicking the label focuses it.
[Parameter] public string? LabelFor { get; set; }
[Parameter] public string? Description { get; set; }
// Shown instead of the EditForm's validation messages for For.
[Parameter] public string? Error { get; set; }
// The model property whose validation messages to show, as in FormField.
[Parameter] public Expression<Func<object?>>? For { get; set; }
[Parameter] public bool Required { get; set; }
// "vertical" or "horizontal" (label and description beside the control, for switches and checkboxes).
[Parameter] public string Orientation { get; set; } = "vertical";
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }

private bool IsHorizontal => string.Equals(Orientation, "horizontal", StringComparison.OrdinalIgnoreCase);

private FieldIdentifier FieldIdentifier => For != null ? FieldIdentifier.Create(For) : default;
}
48 changes: 48 additions & 0 deletions NET10/BlazorInteractiveServer/Components/UI/FormMessage.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
@namespace BlazorInteractiveServer.Components.UI
@using Microsoft.AspNetCore.Components.Forms
@implements IDisposable

@if (Messages.Length > 0)
{
<p class="@Shell.Cn("text-sm font-medium text-destructive", Class)" @attributes="AdditionalAttributes">
@string.Join(", ", Messages)
</p>
}

@code {
[CascadingParameter] private EditContext? EditContext { get; set; }
[CascadingParameter(Name = "ShellUIFormField")] private FieldIdentifier Field { get; set; }
[Parameter] public string? Message { get; set; }
[Parameter] public string? FieldName { get; set; }
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }

private EditContext? _subscribed;

private string[] Messages
{
get
{
if (!string.IsNullOrEmpty(Message)) return new[] { Message };
if (EditContext == null) return Array.Empty<string>();
var target = string.IsNullOrEmpty(FieldName) ? Field : new FieldIdentifier(EditContext.Model, FieldName);
return target.Model == null ? Array.Empty<string>() : EditContext.GetValidationMessages(target).ToArray();
}
}

protected override void OnParametersSet()
{
if (_subscribed == EditContext) return;
if (_subscribed != null) _subscribed.OnValidationStateChanged -= OnValidationStateChanged;
_subscribed = EditContext;
if (_subscribed != null) _subscribed.OnValidationStateChanged += OnValidationStateChanged;
}

private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e) => InvokeAsync(StateHasChanged);

public void Dispose()
{
if (_subscribed != null) _subscribed.OnValidationStateChanged -= OnValidationStateChanged;
}
}
57 changes: 57 additions & 0 deletions NET10/BlazorInteractiveServer/Components/UI/JsonViewer.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
@namespace BlazorInteractiveServer.Components.UI
@using System.Text.Json

<div class="@Shell.Cn("relative overflow-auto rounded-lg border border-border bg-background p-3 font-mono text-xs leading-relaxed", Class)" @attributes="AdditionalAttributes">
@if (_error != null)
{
<p class="text-destructive">@_error</p>
}
else if (_root is { } root)
{
@if (ShowCopy)
{
<CopyButton Text="@_formatted" Class="absolute right-2 top-2 h-7 w-7 bg-background" />
}
@* Keyed on the source so new JSON resets which nodes are open. *@
<JsonViewerNode @key="_source" Element="root" ExpandDepth="ExpandDepth" />
}
</div>

@code {
[Parameter] public string? Json { get; set; }
// Serialized with System.Text.Json when Json is not set.
[Parameter] public object? Value { get; set; }
// Levels open on first render; deeper objects and arrays start collapsed.
[Parameter] public int ExpandDepth { get; set; } = 2;
[Parameter] public bool ShowCopy { get; set; } = true;
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }

private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };

private string? _source;
private JsonElement? _root;
private string? _formatted;
private string? _error;

protected override void OnParametersSet()
{
var source = Json ?? (Value == null ? null : JsonSerializer.Serialize(Value));
if (source == _source) return;
_source = source;
_root = null;
_error = null;
if (string.IsNullOrWhiteSpace(source)) return;
try
{
using var doc = JsonDocument.Parse(source);
_root = doc.RootElement.Clone();
_formatted = JsonSerializer.Serialize(_root.Value, Indented);
}
catch (JsonException ex)
{
_error = $"Invalid JSON: {ex.Message}";
}
}
}
83 changes: 83 additions & 0 deletions NET10/BlazorInteractiveServer/Components/UI/JsonViewerNode.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
@namespace BlazorInteractiveServer.Components.UI
@using System.Text.Json

<div>
@if (IsContainer)
{
<button type="button"
@onclick="Toggle"
aria-expanded="@(_open ? "true" : "false")"
class="inline-flex items-center gap-1 rounded-sm text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<svg class="@Shell.Cn("h-3 w-3 shrink-0 text-muted-foreground transition-transform", _open ? "rotate-90" : "")" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
@if (Name != null)
{
<span><span class="text-sky-700 dark:text-sky-400">"@Name"</span><span class="text-muted-foreground">:</span></span>
}
@if (_open)
{
<span class="text-muted-foreground">@Open</span>
}
else
{
<span class="text-muted-foreground">@Open @Summary @(Close)@Comma</span>
}
</button>
@if (_open)
{
<div class="ml-[5px] border-l border-border pl-3">
@foreach (var (child, name, index) in Children)
{
<JsonViewerNode Element="child" Name="@name" Depth="Depth + 1" ExpandDepth="ExpandDepth" Last="index == Count - 1" />
}
</div>
<span class="ml-4 text-muted-foreground">@(Close)@Comma</span>
}
}
else
{
<span class="ml-4">
@if (Name != null)
{
<span class="text-sky-700 dark:text-sky-400">"@Name"</span><span class="text-muted-foreground">: </span>
}
<span class="@ValueClass">@Element.GetRawText()</span><span class="text-muted-foreground">@Comma</span>
</span>
}
</div>

@code {
[Parameter] public JsonElement Element { get; set; }
// The property name, or null for array items and the root.
[Parameter] public string? Name { get; set; }
[Parameter] public int Depth { get; set; }
[Parameter] public int ExpandDepth { get; set; } = 2;
[Parameter] public bool Last { get; set; } = true;

private bool _open;

private bool IsObject => Element.ValueKind == JsonValueKind.Object;
private bool IsContainer => IsObject || Element.ValueKind == JsonValueKind.Array;
private int Count => IsObject ? Element.EnumerateObject().Count() : IsContainer ? Element.GetArrayLength() : 0;
private string Open => IsObject ? "{" : "[";
private string Close => IsObject ? "}" : "]";
private string Comma => Last ? "" : ",";
private string Summary => IsObject ? (Count == 1 ? "1 key" : $"{Count} keys") : (Count == 1 ? "1 item" : $"{Count} items");

private IEnumerable<(JsonElement Child, string? Name, int Index)> Children => IsObject
? Element.EnumerateObject().Select((p, i) => (p.Value, (string?)p.Name, i))
: Element.EnumerateArray().Select((e, i) => (e, (string?)null, i));

private string ValueClass => Element.ValueKind switch
{
JsonValueKind.String => "text-emerald-700 dark:text-emerald-400",
JsonValueKind.Number => "text-amber-700 dark:text-amber-400",
JsonValueKind.True or JsonValueKind.False => "text-violet-700 dark:text-violet-400",
_ => "text-muted-foreground"
};

protected override void OnInitialized() => _open = Depth < ExpandDepth;

private void Toggle() => _open = !_open;
}
Loading
Loading