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
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ On a fresh database the admin asks for the first administrator at `/setup`; ever

Signed in, `/ui` shows every component in `src/FluentCMS.Admin/Components/Ui` with its keyboard hints. Switch the theme from the header and the direction with the link on the page. A new component goes on that page in the same pull request; a test fails when one is missing.

A shadcn example translates into Razor with the same class names. `ClassNames.Cn` is shadcn's `cn()`: it joins the classes and merges them as tailwind-merge does, so a later class replaces an earlier one that sets the same property and a component's `Class` parameter overrides its own classes. `Variants` is shadcn's `cva()`, with the base classes, the classes for each value of each variant, and the defaults; `ButtonVariants` and `Badge` use it.

`src/FluentCMS.Admin/wwwroot/js/admin.js` is the admin's only JavaScript, and no component calls into it: it listens on the document and reads the page when an event arrives, so behaviour keeps working while the circuit is down and after a reconnect. A keyboard shortcut is declared on the element it triggers with `aria-keyshortcuts`, for example `aria-keyshortcuts="Control+K Meta+K"`, which assistive technology announces as well. The module clicks a button or link and focuses anything else. A shortcut without Control, Alt or Meta waits while someone types in a field, and while a modal dialog is open only the shortcuts inside it apply.

People can also sign in through one OpenID Connect provider (Keycloak, Authentik, Microsoft Entra, Google and similar). Set `Authentication__OpenIdConnect__Authority`, `Authentication__OpenIdConnect__ClientId` and `Authentication__OpenIdConnect__ClientSecret`, optionally `Authentication__OpenIdConnect__DisplayName` for the button, and register `https://<host>/signin-oidc` as the redirect URI at the provider. Without an authority and client id the option does not appear. Users are not created from the provider: the first sign-in links the provider's account to the user with the same email, and only if the provider marks the email as verified. Later sign-ins match on the provider's issuer and subject. A provider that never sends `email_verified`, such as Microsoft Entra, needs `Authentication__OpenIdConnect__AssumeVerifiedEmail=true`; set it only if that provider issues verified addresses alone.
Expand Down
26 changes: 15 additions & 11 deletions src/FluentCMS.Admin/Components/Ui/Badge.razor
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
<span data-slot="badge"
data-variant="@Variant"
class="@ClassNames.Cn(Base, VariantClasses(Variant), Class)"
class="@(BadgeVariants.Classes(new() { ["variant"] = Variant }, Class))"
@attributes="AdditionalAttributes">
@ChildContent
</span>

@code {
// The shadcn badge, so a documentation example translates with the same variant names.
private const string Base = "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3";
// shadcn's badgeVariants, so a documentation example translates with the same variant names.
private static readonly Variants BadgeVariants = new(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
new()
{
["variant"] = new()
{
["default"] = "border-transparent bg-primary text-primary-foreground",
["secondary"] = "border-transparent bg-secondary text-secondary-foreground",
["destructive"] = "border-transparent bg-destructive text-white dark:bg-destructive/60",
["outline"] = "text-foreground",
},
},
new() { ["variant"] = "default" });

[Parameter] public string Variant { get; set; } = "default";
[Parameter] public string? Class { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }

private static string VariantClasses(string variant) => variant switch
{
"secondary" => "border-transparent bg-secondary text-secondary-foreground",
"destructive" => "border-transparent bg-destructive text-white dark:bg-destructive/60",
"outline" => "text-foreground",
_ => "border-transparent bg-primary text-primary-foreground",
};
}
53 changes: 29 additions & 24 deletions src/FluentCMS.Admin/Components/Ui/ButtonVariants.cs
Original file line number Diff line number Diff line change
@@ -1,34 +1,39 @@
namespace FluentCMS.Admin.Components.Ui;

// The shadcn button variants, so a documentation example translates with the same names.
// shadcn's buttonVariants, so a documentation example translates with the same names, and a link can look like a button.
public static class ButtonVariants
{
public const string Base = "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";

private static readonly Variants Variants = new(
Base,
new()
{
["variant"] = new()
{
["default"] = "bg-primary text-primary-foreground hover:bg-primary/90",
["destructive"] = "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
["outline"] = "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
["secondary"] = "bg-secondary text-secondary-foreground hover:bg-secondary/80",
["ghost"] = "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
["link"] = "text-primary underline-offset-4 hover:underline",
},
["size"] = new()
{
["default"] = "h-9 px-4 py-2 has-[>svg]:px-3",
["xs"] = "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
["sm"] = "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
["lg"] = "h-10 rounded-md px-6 has-[>svg]:px-4",
["icon"] = "size-9",
["icon-xs"] = "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
["icon-sm"] = "size-8",
["icon-lg"] = "size-10",
},
},
new() { ["variant"] = "default", ["size"] = "default" });

public static string Classes(string variant = "default", string size = "default", string? className = null)
{
return ClassNames.Cn(Base, Variant(variant), Size(size), className);
return Variants.Classes(new() { ["variant"] = variant, ["size"] = size }, className);
}

private static string Variant(string variant) => variant switch
{
"destructive" => "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
"outline" => "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
"secondary" => "bg-secondary text-secondary-foreground hover:bg-secondary/80",
"ghost" => "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"link" => "text-primary underline-offset-4 hover:underline",
_ => "bg-primary text-primary-foreground hover:bg-primary/90",
};

private static string Size(string size) => size switch
{
"xs" => "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
"sm" => "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
"lg" => "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon" => "size-9",
"icon-xs" => "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm" => "size-8",
"icon-lg" => "size-10",
_ => "h-9 px-4 py-2 has-[>svg]:px-3",
};
}
5 changes: 3 additions & 2 deletions src/FluentCMS.Admin/Components/Ui/ClassNames.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
namespace FluentCMS.Admin.Components.Ui;

// Joins class fragments the way shadcn's cn() does. Conflict resolution (tailwind-merge) comes with the component set.
// shadcn's cn(): joins class fragments, skipping empty ones, and merges them so a later class replaces an earlier one that
// sets the same property (TailwindMerge). A component's Class parameter goes last, so it overrides the component's classes.
public static class ClassNames
{
public static string Cn(params string?[] classes)
{
return string.Join(' ', classes.Where(c => !string.IsNullOrWhiteSpace(c)));
return TailwindMerge.Merge(string.Join(' ', classes.Where(c => !string.IsNullOrWhiteSpace(c))));
}
}

Expand Down
Loading
Loading