feat(console-web): add variant-based UI primitives

Introduce cva-driven Button, form, card, table, and segmented controls.

Use clsx and tailwind-merge through a shared cn helper so callers can override component defaults without relying on global semantic CSS. Control sizes define minimum heights while icon-only actions retain stable square dimensions.
This commit is contained in:
Maofeng
2026-07-30 15:16:02 +08:00
parent 868da49a52
commit 23fa6137ff
11 changed files with 513 additions and 92 deletions
+35 -91
View File
@@ -8,8 +8,11 @@
"name": "wasmeld-console-web",
"version": "0.1.0",
"dependencies": {
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-solid": "1.27.0",
"solid-js": "1.9.14"
"solid-js": "1.9.14",
"tailwind-merge": "3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
@@ -545,9 +548,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -565,9 +565,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -585,9 +582,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -605,9 +599,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -625,9 +616,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -645,9 +633,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -665,9 +650,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -685,9 +667,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -892,9 +871,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -912,9 +888,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -932,9 +905,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -952,9 +922,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -972,9 +939,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -992,9 +956,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1012,9 +973,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1032,9 +990,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1205,9 +1160,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1225,9 +1177,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1245,9 +1194,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1265,9 +1211,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1285,9 +1228,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1305,9 +1245,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1550,9 +1487,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1570,9 +1504,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1590,9 +1521,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1610,9 +1538,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2224,6 +2149,27 @@
],
"license": "CC-BY-4.0"
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2565,9 +2511,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2589,9 +2532,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2613,9 +2553,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2637,9 +2574,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3040,6 +2974,16 @@
"node": ">=0.10.0"
}
},
"node_modules/tailwind-merge": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+4 -1
View File
@@ -13,8 +13,11 @@
"format:check": "oxfmt --check ."
},
"dependencies": {
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-solid": "1.27.0",
"solid-js": "1.9.14"
"solid-js": "1.9.14",
"tailwind-merge": "3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
@@ -0,0 +1,113 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md border text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45",
{
variants: {
variant: {
primary: "border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong",
secondary: "border-line bg-white text-ink hover:bg-canvas",
danger: "border-coral-strong bg-white text-coral-strong hover:bg-coral-soft",
},
size: {
default: "min-h-10 px-4 py-2",
small: "min-h-9 px-4 py-1.5",
},
},
defaultVariants: {
variant: "secondary",
size: "default",
},
},
);
export type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
/**
* Standard text or icon-and-text command button.
*
* Size variants define a minimum target height instead of fixing the block
* height, so wrapped labels and enlarged user fonts can grow without clipping.
* The component defaults to `type="button"` so using it inside a form never
* submits accidentally. Set `type="submit"` explicitly for form actions.
*/
export function Button(props: ButtonProps) {
const [local, rest] = splitProps(props, ["variant", "size", "class", "children", "type"]);
return (
<button
{...rest}
data-slot="button"
type={local.type ?? "button"}
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
>
{local.children}
</button>
);
}
export const iconButtonVariants = cva(
"inline-flex shrink-0 items-center justify-center rounded-md border border-line bg-white text-muted transition-colors hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-45",
{
variants: {
tone: {
default: "",
danger: "text-coral-strong",
},
size: {
default: "size-9",
small: "size-8",
},
},
defaultVariants: {
tone: "default",
size: "default",
},
},
);
export type IconButtonProps = Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, "aria-label"> &
VariantProps<typeof iconButtonVariants> & {
"aria-label": string;
};
/**
* Square icon-only button for familiar toolbar and row actions.
*
* Callers must provide an accessible `aria-label`; `title` is recommended when
* the icon's meaning benefits from a visible hover explanation.
*/
export function IconButton(props: IconButtonProps) {
const [local, rest] = splitProps(props, ["tone", "size", "class", "children", "type"]);
return (
<button
{...rest}
data-slot="icon-button"
type={local.type ?? "button"}
class={cn(iconButtonVariants({ tone: local.tone, size: local.size }), local.class)}
>
{local.children}
</button>
);
}
export type IconLinkProps = Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "aria-label"> &
Pick<VariantProps<typeof iconButtonVariants>, "size"> & {
"aria-label": string;
};
/** Anchor counterpart to IconButton for downloads and external navigation. */
export function IconLink(props: IconLinkProps) {
const [local, rest] = splitProps(props, ["size", "class", "children"]);
return (
<a
{...rest}
data-slot="icon-link"
class={cn(iconButtonVariants({ size: local.size }), local.class)}
>
{local.children}
</a>
);
}
@@ -0,0 +1,68 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { Dynamic } from "solid-js/web";
import { cn } from "../../lib/cn";
type CardElement = "article" | "aside" | "div" | "section";
export const cardVariants = cva("rounded-md border border-line bg-surface", {
variants: {
padding: {
none: "",
small: "p-4",
medium: "p-5",
},
elevation: {
flat: "",
raised: "shadow-sm",
},
},
defaultVariants: {
padding: "none",
elevation: "flat",
},
});
export type CardProps = JSX.HTMLAttributes<HTMLElement> &
VariantProps<typeof cardVariants> & {
as?: CardElement;
};
/**
* Generic bordered surface.
*
* `as` preserves document semantics at the call site; Card only owns visual
* framing and must not encode page or domain behavior.
*/
export function Card(props: CardProps) {
const [local, rest] = splitProps(props, ["as", "padding", "elevation", "class", "children"]);
return (
<Dynamic
component={local.as ?? "div"}
{...rest}
data-slot="card"
class={cn(cardVariants({ padding: local.padding, elevation: local.elevation }), local.class)}
>
{local.children}
</Dynamic>
);
}
export type CardHeaderProps = JSX.HTMLAttributes<HTMLDivElement>;
/** Standard Card heading/action row. */
export function CardHeader(props: CardHeaderProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<div
{...rest}
data-slot="card-header"
class={cn(
"flex min-h-16 items-center justify-between gap-4 border-b border-line px-5 py-3 [&_h2]:text-base [&_h2]:font-semibold [&_p]:mt-0.5 [&_p]:text-xs [&_p]:text-muted",
local.class,
)}
>
{local.children}
</div>
);
}
@@ -0,0 +1,34 @@
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export type FieldProps = JSX.LabelHTMLAttributes<HTMLLabelElement>;
/**
* Associates a label and form control while keeping field layout consistent.
*
* Keep validation and help text at the call site: those messages are domain
* content, while Field only owns spacing and label typography.
*/
export function Field(props: FieldProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<label {...rest} data-slot="field" class={cn("block", local.class)}>
{local.children}
</label>
);
}
export type FieldLabelProps = JSX.HTMLAttributes<HTMLSpanElement>;
export function FieldLabel(props: FieldLabelProps) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<span
{...rest}
data-slot="field-label"
class={cn("mb-1.5 block text-xs font-semibold text-muted", local.class)}
>
{local.children}
</span>
);
}
@@ -0,0 +1,33 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const inputVariants = cva(
"w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
{
variants: {
density: {
default: "min-h-10 py-2",
compact: "min-h-9 py-1.5",
},
},
defaultVariants: {
density: "default",
},
},
);
export type InputProps = JSX.InputHTMLAttributes<HTMLInputElement> &
VariantProps<typeof inputVariants>;
/** Native single-line input with a minimum, rather than fixed, control height. */
export function Input(props: InputProps) {
const [local, rest] = splitProps(props, ["density", "class"]);
return (
<input
{...rest}
data-slot="input"
class={cn(inputVariants({ density: local.density }), local.class)}
/>
);
}
@@ -0,0 +1,50 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export function SegmentedControl(props: JSX.HTMLAttributes<HTMLDivElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<div
{...rest}
data-slot="segmented-control"
class={cn("inline-flex rounded-md border border-line bg-canvas p-1", local.class)}
>
{local.children}
</div>
);
}
export const segmentVariants = cva(
"min-h-8 rounded-sm px-3 py-1 text-xs font-semibold text-muted transition-colors",
{
variants: {
active: {
true: "bg-white text-ink shadow-sm",
false: "hover:text-ink",
},
},
defaultVariants: {
active: false,
},
},
);
export type SegmentProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof segmentVariants>;
/** One mutually exclusive option inside a SegmentedControl. */
export function Segment(props: SegmentProps) {
const [local, rest] = splitProps(props, ["active", "class", "children", "type"]);
return (
<button
{...rest}
data-slot="segment"
type={local.type ?? "button"}
aria-pressed={local.active === true}
class={cn(segmentVariants({ active: local.active }), local.class)}
>
{local.children}
</button>
);
}
@@ -0,0 +1,35 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const selectVariants = cva(
"w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
{
variants: {
density: {
default: "min-h-10 py-2",
compact: "min-h-9 py-1.5",
},
},
defaultVariants: {
density: "default",
},
},
);
export type SelectProps = JSX.SelectHTMLAttributes<HTMLSelectElement> &
VariantProps<typeof selectVariants>;
/** Native select with browser behavior preserved and a content-growable height. */
export function Select(props: SelectProps) {
const [local, rest] = splitProps(props, ["density", "class", "children"]);
return (
<select
{...rest}
data-slot="select"
class={cn(selectVariants({ density: local.density }), local.class)}
>
{local.children}
</select>
);
}
@@ -0,0 +1,82 @@
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export type TableProps = JSX.HTMLAttributes<HTMLTableElement> & {
containerClass?: string;
};
/** Responsive native table with a horizontal overflow boundary. */
export function Table(props: TableProps) {
const [local, rest] = splitProps(props, ["class", "containerClass", "children"]);
return (
<div data-slot="table-container" class={cn("w-full overflow-x-auto", local.containerClass)}>
<table
{...rest}
data-slot="table"
class={cn("w-full min-w-[760px] border-collapse text-left text-sm", local.class)}
>
{local.children}
</table>
</div>
);
}
export function TableHeader(props: JSX.HTMLAttributes<HTMLTableSectionElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<thead {...rest} data-slot="table-header" class={cn(local.class)}>
{local.children}
</thead>
);
}
export function TableBody(props: JSX.HTMLAttributes<HTMLTableSectionElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<tbody {...rest} data-slot="table-body" class={cn(local.class)}>
{local.children}
</tbody>
);
}
export function TableRow(props: JSX.HTMLAttributes<HTMLTableRowElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<tr
{...rest}
data-slot="table-row"
class={cn("transition-colors hover:bg-[#f8faf9] [&:last-child_td]:border-b-0", local.class)}
>
{local.children}
</tr>
);
}
export function TableHead(props: JSX.ThHTMLAttributes<HTMLTableCellElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<th
{...rest}
data-slot="table-head"
class={cn(
"border-b border-line bg-canvas px-4 py-2.5 text-[11px] font-semibold uppercase text-muted",
local.class,
)}
>
{local.children}
</th>
);
}
export function TableCell(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
const [local, rest] = splitProps(props, ["class", "children"]);
return (
<td
{...rest}
data-slot="table-cell"
class={cn("border-b border-line px-4 py-3 align-middle", local.class)}
>
{local.children}
</td>
);
}
@@ -0,0 +1,43 @@
import { cva, type VariantProps } from "class-variance-authority";
import { splitProps, type JSX } from "solid-js";
import { cn } from "../../lib/cn";
export const textareaVariants = cva(
"min-h-36 w-full rounded-md border border-line bg-white px-3 py-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong",
{
variants: {
resize: {
vertical: "resize-y",
none: "resize-none",
},
typography: {
mono: "font-mono",
sans: "font-sans",
},
},
defaultVariants: {
resize: "vertical",
typography: "mono",
},
},
);
export type TextareaProps = JSX.TextareaHTMLAttributes<HTMLTextAreaElement> &
VariantProps<typeof textareaVariants>;
/** Standard multiline input with explicit resize and typography variants. */
export function Textarea(props: TextareaProps) {
const [local, rest] = splitProps(props, ["resize", "typography", "class", "children"]);
return (
<textarea
{...rest}
data-slot="textarea"
class={cn(
textareaVariants({ resize: local.resize, typography: local.typography }),
local.class,
)}
>
{local.children}
</textarea>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merges conditional Tailwind classes using the same pattern as shadcn.
*
* `clsx` evaluates conditional input while `tailwind-merge` lets a caller's
* class override a component default from the same Tailwind class group:
*
* ```ts
* cn("h-10 px-4", compact && "h-8", props.class)
* ```
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}