Card Component Added

This commit is contained in:
2026-04-13 15:39:41 +05:00
parent 06ec22704b
commit c23c598b0b
13 changed files with 513 additions and 2 deletions
@@ -57,6 +57,16 @@
</Icon> </Icon>
<ChildContent>Forms</ChildContent> <ChildContent>Forms</ChildContent>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem Href="/cards" Tooltip="Cards">
<Icon>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M3 9h18" />
</svg>
</Icon>
<ChildContent>Cards</ChildContent>
</SidebarMenuItem>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
@@ -0,0 +1,67 @@
@page "/cards"
@rendermode InteractiveServer
<PageTitle>Cards</PageTitle>
<div class="space-y-8 max-w-lg">
<div>
<h1 class="text-3xl font-bold tracking-tight">Cards Demo</h1>
<p class="text-muted-foreground">Card components with multiple layouts.</p>
</div>
@* ── Basic card ── *@
<Card data-testid="card-basic">
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card Description</CardDescription>
</CardHeader>
<CardContent>
<p>Card Content</p>
</CardContent>
<CardFooter>
<p>Card Footer</p>
</CardFooter>
</Card>
@* ── Card with action in header ── *@
<Card data-testid="card-login">
<CardHeader>
<CardTitle>Login to your account</CardTitle>
<CardAction>
<Button Variant="@ButtonVariant.Link" Class="text-sm">Sign Up</Button>
</CardAction>
<CardDescription>Enter your email below to login to your account</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-4">
<FormField Label="Email" For="email">
<TextInput Id="email" Type="email" Placeholder="m@example.com" />
</FormField>
<FormField Label="Password" For="password">
<TextInput Id="password" Type="password" />
</FormField>
</div>
</CardContent>
<CardFooter Class="flex-col gap-2">
<Button Class="w-full">Login</Button>
<Button Variant="@ButtonVariant.Outline" Class="w-full">Login with Google</Button>
</CardFooter>
</Card>
@* ── Image card ── *@
<Card data-testid="card-image">
<CardImage Src="https://placehold.co/600x300/2a2a2a/2a2a2a" Alt="Event cover" WrapperClass="h-48" />
<CardHeader>
<CardTitle>Design systems meetup</CardTitle>
<CardAction>
<span class="rounded-full border border-border px-3 py-0.5 text-xs font-medium text-muted-foreground">
Featured
</span>
</CardAction>
<CardDescription>A practical talk on component APIs, accessibility, and shipping faster.</CardDescription>
</CardHeader>
<CardFooter>
<Button Class="w-full">View Event</Button>
</CardFooter>
</Card>
</div>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,250 @@
using Microsoft.Playwright;
namespace Enciphered.Blazor.UIComponents.Tests;
[TestFixture]
public class CardTests : PlaywrightTestBase
{
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task GoToCardsAsync()
{
await Page.GotoAsync($"{BaseUrl}/cards", new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
await Page.WaitForSelectorAsync("[data-testid='card-basic']", new PageWaitForSelectorOptions { Timeout = 10_000 });
}
private ILocator Card(string testId) => Page.Locator($"[data-testid='{testId}']");
// ────────────────────────────────────────────
// Basic card structure
// ────────────────────────────────────────────
[Test]
public async Task BasicCard_Is_Visible()
{
await GoToCardsAsync();
await Expect(Card("card-basic")).ToBeVisibleAsync();
}
[Test]
public async Task BasicCard_Has_Rounded_Border_Classes()
{
await GoToCardsAsync();
var card = Card("card-basic");
await Expect(card).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("rounded-xl"));
await Expect(card).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("border"));
}
[Test]
public async Task BasicCard_Renders_Title()
{
await GoToCardsAsync();
var title = Card("card-basic").Locator("h3");
await Expect(title).ToBeVisibleAsync();
await Expect(title).ToHaveTextAsync("Card Title");
}
[Test]
public async Task BasicCard_Renders_Description()
{
await GoToCardsAsync();
var desc = Card("card-basic").Locator("[data-slot='card-description']");
await Expect(desc).ToBeVisibleAsync();
await Expect(desc).ToHaveTextAsync("Card Description");
}
[Test]
public async Task BasicCard_Renders_Content()
{
await GoToCardsAsync();
var content = Card("card-basic").Locator("p:has-text('Card Content')");
await Expect(content).ToBeVisibleAsync();
}
[Test]
public async Task BasicCard_Renders_Footer()
{
await GoToCardsAsync();
var footer = Card("card-basic").Locator("p:has-text('Card Footer')");
await Expect(footer).ToBeVisibleAsync();
}
[Test]
public async Task BasicCard_Description_Appears_Below_Title()
{
await GoToCardsAsync();
var title = Card("card-basic").Locator("h3");
var desc = Card("card-basic").Locator("[data-slot='card-description']");
var titleBox = await title.BoundingBoxAsync();
var descBox = await desc.BoundingBoxAsync();
Assert.That(titleBox, Is.Not.Null, "Title bounding box should exist");
Assert.That(descBox, Is.Not.Null, "Description bounding box should exist");
Assert.That(descBox!.Y, Is.GreaterThan(titleBox!.Y), "Description should be positioned below the title");
}
// ────────────────────────────────────────────
// Login card header with action
// ────────────────────────────────────────────
[Test]
public async Task LoginCard_Is_Visible()
{
await GoToCardsAsync();
await Expect(Card("card-login")).ToBeVisibleAsync();
}
[Test]
public async Task LoginCard_Renders_Title()
{
await GoToCardsAsync();
var title = Card("card-login").Locator("h3");
await Expect(title).ToHaveTextAsync("Login to your account");
}
[Test]
public async Task LoginCard_Renders_Action()
{
await GoToCardsAsync();
var action = Card("card-login").Locator("[data-slot='card-action']");
await Expect(action).ToBeVisibleAsync();
await Expect(action).ToContainTextAsync("Sign Up");
}
[Test]
public async Task LoginCard_Action_Is_Right_Of_Title()
{
await GoToCardsAsync();
var title = Card("card-login").Locator("h3");
var action = Card("card-login").Locator("[data-slot='card-action']");
var titleBox = await title.BoundingBoxAsync();
var actionBox = await action.BoundingBoxAsync();
Assert.That(titleBox, Is.Not.Null);
Assert.That(actionBox, Is.Not.Null);
Assert.That(actionBox!.X, Is.GreaterThan(titleBox!.X), "Action should be to the right of the title");
}
[Test]
public async Task LoginCard_Renders_Description()
{
await GoToCardsAsync();
var desc = Card("card-login").Locator("[data-slot='card-description']");
await Expect(desc).ToContainTextAsync("Enter your email below");
}
[Test]
public async Task LoginCard_Has_Email_Input()
{
await GoToCardsAsync();
var email = Card("card-login").Locator("input#email");
await Expect(email).ToBeVisibleAsync();
}
[Test]
public async Task LoginCard_Has_Password_Input()
{
await GoToCardsAsync();
var password = Card("card-login").Locator("input#password");
await Expect(password).ToBeVisibleAsync();
}
[Test]
public async Task LoginCard_Has_Login_Button()
{
await GoToCardsAsync();
var btn = Card("card-login").Locator("button:has-text('Login')").First;
await Expect(btn).ToBeVisibleAsync();
}
[Test]
public async Task LoginCard_Has_Google_Button()
{
await GoToCardsAsync();
var btn = Card("card-login").Locator("button:has-text('Login with Google')");
await Expect(btn).ToBeVisibleAsync();
}
// ────────────────────────────────────────────
// Image card
// ────────────────────────────────────────────
[Test]
public async Task ImageCard_Is_Visible()
{
await GoToCardsAsync();
await Expect(Card("card-image")).ToBeVisibleAsync();
}
[Test]
public async Task ImageCard_Renders_Image()
{
await GoToCardsAsync();
var img = Card("card-image").Locator("img");
await Expect(img).ToBeVisibleAsync();
await Expect(img).ToHaveAttributeAsync("alt", "Event cover");
}
[Test]
public async Task ImageCard_Image_Appears_Above_Title()
{
await GoToCardsAsync();
var img = Card("card-image").Locator("img");
var title = Card("card-image").Locator("h3");
var imgBox = await img.BoundingBoxAsync();
var titleBox = await title.BoundingBoxAsync();
Assert.That(imgBox, Is.Not.Null);
Assert.That(titleBox, Is.Not.Null);
Assert.That(titleBox!.Y, Is.GreaterThan(imgBox!.Y), "Title should be below the image");
}
[Test]
public async Task ImageCard_Renders_Title()
{
await GoToCardsAsync();
var title = Card("card-image").Locator("h3");
await Expect(title).ToHaveTextAsync("Design systems meetup");
}
[Test]
public async Task ImageCard_Renders_Featured_Badge()
{
await GoToCardsAsync();
var badge = Card("card-image").Locator("[data-slot='card-action']");
await Expect(badge).ToContainTextAsync("Featured");
}
[Test]
public async Task ImageCard_Renders_Description()
{
await GoToCardsAsync();
var desc = Card("card-image").Locator("[data-slot='card-description']");
await Expect(desc).ToContainTextAsync("A practical talk on component APIs");
}
[Test]
public async Task ImageCard_Has_View_Event_Button()
{
await GoToCardsAsync();
var btn = Card("card-image").Locator("button:has-text('View Event')");
await Expect(btn).ToBeVisibleAsync();
}
// ────────────────────────────────────────────
// All three cards render on the page
// ────────────────────────────────────────────
[Test]
public async Task CardsPage_Renders_Three_Cards()
{
await GoToCardsAsync();
await Expect(Card("card-basic")).ToBeVisibleAsync();
await Expect(Card("card-login")).ToBeVisibleAsync();
await Expect(Card("card-image")).ToBeVisibleAsync();
}
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
/// <summary>The card's inner content (CardHeader, CardContent, CardFooter, etc.).</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes appended to the card wrapper.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<div data-slot="card-action" class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
/// <summary>Action content rendered at the trailing edge of the header (e.g. a link or button).</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"self-start ml-auto";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,21 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
/// <summary>Main body content of the card.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass = "p-6 pt-0";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<p data-slot="card-description" class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</p>
@code {
/// <summary>Description text or markup rendered below the title.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"text-sm text-muted-foreground";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
/// <summary>Footer content — typically action buttons.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"flex items-center p-6 pt-0";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</div>
@code {
/// <summary>Header content — typically CardTitle, CardDescription, and CardAction.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"flex flex-col gap-1.5 p-6 has-[data-slot=card-action]:grid has-[data-slot=card-action]:grid-cols-[1fr_auto] has-[data-slot=card-action]:items-center [&>[data-slot=card-description]]:col-span-full";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
@@ -0,0 +1,31 @@
@namespace Enciphered.Blazor.UIComponents
<div class="@ComputedWrapperClass">
<img src="@Src" alt="@Alt" class="@ComputedImageClass" @attributes="AdditionalAttributes" />
</div>
@code {
/// <summary>Image source URL.</summary>
[Parameter, EditorRequired] public string Src { get; set; } = string.Empty;
/// <summary>Image alt text for accessibility.</summary>
[Parameter] public string Alt { get; set; } = string.Empty;
/// <summary>Additional CSS classes for the image element.</summary>
[Parameter] public string? Class { get; set; }
/// <summary>Additional CSS classes for the wrapper div.</summary>
[Parameter] public string? WrapperClass { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseImageClass = "w-full h-full object-cover";
private const string BaseWrapperClass = "overflow-hidden";
private string ComputedImageClass =>
string.IsNullOrEmpty(Class) ? BaseImageClass : $"{BaseImageClass} {Class}";
private string ComputedWrapperClass =>
string.IsNullOrEmpty(WrapperClass) ? BaseWrapperClass : $"{BaseWrapperClass} {WrapperClass}";
}
@@ -0,0 +1,22 @@
@namespace Enciphered.Blazor.UIComponents
<h3 class="@ComputedClass" @attributes="AdditionalAttributes">
@ChildContent
</h3>
@code {
/// <summary>Title text or markup.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Additional CSS classes.</summary>
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private const string BaseClass =
"text-lg font-semibold leading-none tracking-tight";
private string ComputedClass =>
string.IsNullOrEmpty(Class) ? BaseClass : $"{BaseClass} {Class}";
}
File diff suppressed because one or more lines are too long