738 lines
31 KiB
JavaScript
738 lines
31 KiB
JavaScript
// ============================================================
|
|
// vibn-ai-templates/components.jsx
|
|
// ------------------------------------------------------------
|
|
// The core component set. Every visual property is wired to a
|
|
// CSS variable from tokens.css — flipping `class="theme-glass"`
|
|
// (or any other theme class) reskins the whole library.
|
|
//
|
|
// Components export to `window` for use in script-tag HTML
|
|
// projects. In a real codebase, swap the bottom-of-file
|
|
// assignment for `export { … }`.
|
|
//
|
|
// Components included:
|
|
// Button, IconButton, Field, Input, Textarea, Select,
|
|
// Checkbox, Radio, Switch, Card, Badge, Tag, Avatar,
|
|
// AvatarStack, Tabs, Table, Modal, Banner, Divider,
|
|
// FieldGroup, KBD, Spinner.
|
|
// ============================================================
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────
|
|
const cx = (...names) => names.filter(Boolean).join(" ");
|
|
const noop = () => {};
|
|
|
|
// ─── Button ──────────────────────────────────────────────────
|
|
// variant: primary (default), secondary, ghost, destructive
|
|
// size: sm | md (default) | lg
|
|
// leadingIcon / trailingIcon: <Icon name="…"/>
|
|
// loading: disables and shows a spinner
|
|
const Button = ({
|
|
children, variant = "primary", size = "md", full = false,
|
|
leadingIcon, trailingIcon, loading, disabled, onClick = noop, style, type = "button",
|
|
...rest
|
|
}) => {
|
|
const sizing = {
|
|
sm: { padY: 6, padX: 12, font: "var(--text-sm)", iconSize: 13 },
|
|
md: { padY: 9, padX: 16, font: "var(--text-md)", iconSize: 15 },
|
|
lg: { padY: 12, padX: 22, font: "var(--text-lg)", iconSize: 16 },
|
|
}[size];
|
|
|
|
const variants = {
|
|
primary: {
|
|
background: "var(--button-bg)",
|
|
color: "var(--button-fg)",
|
|
border: "1px solid var(--button-border)",
|
|
},
|
|
secondary: {
|
|
background: "var(--button-secondary-bg)",
|
|
color: "var(--button-secondary-fg)",
|
|
border: "1px solid var(--button-secondary-border)",
|
|
},
|
|
ghost: {
|
|
background: "transparent",
|
|
color: "var(--button-ghost-fg)",
|
|
border: "1px solid transparent",
|
|
},
|
|
destructive: {
|
|
background: "var(--danger)",
|
|
color: "#ffffff",
|
|
border: "1px solid var(--danger)",
|
|
},
|
|
}[variant];
|
|
|
|
return (
|
|
<button
|
|
type={type}
|
|
disabled={disabled || loading}
|
|
onClick={onClick}
|
|
style={{
|
|
...variants,
|
|
padding: `${sizing.padY}px ${sizing.padX}px`,
|
|
borderRadius: "var(--button-radius)",
|
|
fontFamily: "var(--font-sans)",
|
|
fontSize: sizing.font,
|
|
fontWeight: "var(--weight-medium)",
|
|
lineHeight: 1.2,
|
|
cursor: disabled || loading ? "not-allowed" : "pointer",
|
|
opacity: disabled ? 0.5 : 1,
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
width: full ? "100%" : "auto",
|
|
whiteSpace: "nowrap",
|
|
transition: "background var(--duration) var(--ease), transform var(--duration-fast) var(--ease)",
|
|
...style,
|
|
}}
|
|
{...rest}
|
|
>
|
|
{loading ? <Spinner size={sizing.iconSize}/> : leadingIcon}
|
|
<span>{children}</span>
|
|
{!loading && trailingIcon}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// ─── IconButton ──────────────────────────────────────────────
|
|
const IconButton = ({ icon, name, size = "md", variant = "ghost", onClick = noop, label, style }) => {
|
|
const dims = { sm: 28, md: 32, lg: 38 }[size];
|
|
const iconSize = { sm: 14, md: 16, lg: 18 }[size];
|
|
const variants = {
|
|
ghost: { background: "transparent", color: "var(--text-2)", border: "1px solid transparent" },
|
|
secondary: { background: "var(--button-secondary-bg)", color: "var(--button-secondary-fg)",
|
|
border: "1px solid var(--button-secondary-border)" },
|
|
}[variant];
|
|
return (
|
|
<button
|
|
aria-label={label}
|
|
onClick={onClick}
|
|
style={{
|
|
...variants,
|
|
width: dims, height: dims, borderRadius: "var(--radius-sm)",
|
|
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
cursor: "pointer", padding: 0,
|
|
...style,
|
|
}}
|
|
>
|
|
{icon ?? <Icon name={name} size={iconSize} />}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// ─── Spinner ─────────────────────────────────────────────────
|
|
const Spinner = ({ size = 14, stroke = 2 }) => (
|
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.2" strokeWidth={stroke} />
|
|
<path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round">
|
|
<animateTransform attributeName="transform" type="rotate"
|
|
from="0 12 12" to="360 12 12" dur="0.9s" repeatCount="indefinite"/>
|
|
</path>
|
|
</svg>
|
|
);
|
|
|
|
// ─── Field (wraps a labelled input with hint / error) ────────
|
|
const Field = ({ label, hint, error, optional, htmlFor, children, style }) => (
|
|
<div style={{ marginBottom: "var(--space-4)", ...style }}>
|
|
{label && (
|
|
<label htmlFor={htmlFor} style={{
|
|
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
|
fontSize: "var(--text-sm)", fontWeight: "var(--weight-medium)",
|
|
color: "var(--text)", marginBottom: 6,
|
|
}}>
|
|
<span>{label}</span>
|
|
{optional && <span style={{ color: "var(--text-3)", fontWeight: 400 }}>optional</span>}
|
|
</label>
|
|
)}
|
|
{children}
|
|
{(hint || error) && (
|
|
<div style={{
|
|
fontSize: "var(--text-xs)", marginTop: 5,
|
|
color: error ? "var(--danger)" : "var(--text-3)",
|
|
}}>{error || hint}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// ─── Input ───────────────────────────────────────────────────
|
|
// Bare input (use inside <Field>). leadingIcon / trailingIcon
|
|
// add an inner ornament. invalid red-rings the border.
|
|
const Input = ({
|
|
value, placeholder, type = "text", leadingIcon, trailingIcon,
|
|
invalid, disabled, autofocus, onChange = noop, id, style, ...rest
|
|
}) => (
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 8,
|
|
padding: "10px 12px",
|
|
borderRadius: "var(--field-radius)",
|
|
background: "var(--field-bg)",
|
|
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
|
|
boxShadow: autofocus ? "var(--shadow-focus)" : "var(--shadow-sm)",
|
|
fontSize: "var(--text-md)",
|
|
color: "var(--text)",
|
|
backdropFilter: "blur(var(--surface-blur))",
|
|
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
|
opacity: disabled ? 0.5 : 1,
|
|
transition: "border-color var(--duration), box-shadow var(--duration)",
|
|
...style,
|
|
}}>
|
|
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
|
|
<input
|
|
id={id}
|
|
type={type}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
onChange={(e) => onChange(e.target.value, e)}
|
|
style={{
|
|
flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent",
|
|
fontFamily: "inherit", fontSize: "inherit", color: "inherit",
|
|
padding: 0,
|
|
}}
|
|
{...rest}
|
|
/>
|
|
{trailingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{trailingIcon}</span>}
|
|
</div>
|
|
);
|
|
|
|
// ─── Textarea ────────────────────────────────────────────────
|
|
const Textarea = ({ value, placeholder, rows = 4, onChange = noop, invalid, id, style, ...rest }) => (
|
|
<textarea
|
|
id={id}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
rows={rows}
|
|
onChange={(e) => onChange(e.target.value, e)}
|
|
style={{
|
|
width: "100%", display: "block", padding: "10px 12px",
|
|
borderRadius: "var(--field-radius)",
|
|
background: "var(--field-bg)",
|
|
border: `1px solid ${invalid ? "var(--danger)" : "var(--field-border)"}`,
|
|
fontSize: "var(--text-md)", color: "var(--text)",
|
|
fontFamily: "var(--font-sans)", resize: "vertical",
|
|
outline: "none", boxShadow: "var(--shadow-sm)",
|
|
backdropFilter: "blur(var(--surface-blur))",
|
|
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
|
...style,
|
|
}}
|
|
{...rest}
|
|
/>
|
|
);
|
|
|
|
// ─── Select (presentation only — clicks the menu open visually) ─
|
|
const Select = ({ value, placeholder, options = [], leadingIcon, style, ...rest }) => (
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 8,
|
|
padding: "10px 12px", borderRadius: "var(--field-radius)",
|
|
background: "var(--field-bg)", border: "1px solid var(--field-border)",
|
|
fontSize: "var(--text-md)", color: value ? "var(--text)" : "var(--text-3)",
|
|
cursor: "pointer", boxShadow: "var(--shadow-sm)",
|
|
backdropFilter: "blur(var(--surface-blur))",
|
|
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
|
...style,
|
|
}} {...rest}>
|
|
{leadingIcon && <span style={{ color: "var(--text-3)", display: "flex" }}>{leadingIcon}</span>}
|
|
<span style={{ flex: 1 }}>{value || placeholder}</span>
|
|
<Icon name="chevDown" size={14} style={{ color: "var(--text-3)" }} />
|
|
</div>
|
|
);
|
|
|
|
// ─── Checkbox ────────────────────────────────────────────────
|
|
const Checkbox = ({ checked, indeterminate, disabled, label, hint, onChange = noop, style }) => (
|
|
<label style={{
|
|
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
|
|
opacity: disabled ? 0.5 : 1, ...style,
|
|
}}>
|
|
<span
|
|
role="checkbox"
|
|
aria-checked={indeterminate ? "mixed" : !!checked}
|
|
onClick={() => !disabled && onChange(!checked)}
|
|
style={{
|
|
width: 16, height: 16, borderRadius: 4, marginTop: 1, flexShrink: 0,
|
|
border: `1px solid ${checked || indeterminate ? "var(--accent)" : "var(--border-strong)"}`,
|
|
background: checked || indeterminate ? "var(--accent)" : "var(--surface)",
|
|
color: "var(--text-on-accent)",
|
|
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
transition: "background var(--duration), border-color var(--duration)",
|
|
}}
|
|
>
|
|
{checked && !indeterminate && <Icon name="checkOnly" size={11} stroke={2.6}/>}
|
|
{indeterminate && <div style={{ width: 8, height: 2, background: "currentColor", borderRadius: 1 }}/>}
|
|
</span>
|
|
{(label || hint) && (
|
|
<span style={{ minWidth: 0 }}>
|
|
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
|
|
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
|
|
</span>
|
|
)}
|
|
</label>
|
|
);
|
|
|
|
// ─── Radio ───────────────────────────────────────────────────
|
|
const Radio = ({ checked, disabled, label, hint, onChange = noop, style }) => (
|
|
<label style={{
|
|
display: "flex", alignItems: "flex-start", gap: 10, cursor: disabled ? "not-allowed" : "pointer",
|
|
opacity: disabled ? 0.5 : 1, ...style,
|
|
}}>
|
|
<span
|
|
role="radio"
|
|
aria-checked={!!checked}
|
|
onClick={() => !disabled && onChange(true)}
|
|
style={{
|
|
width: 16, height: 16, borderRadius: "50%", marginTop: 1, flexShrink: 0,
|
|
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
|
|
background: "var(--surface)",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{checked && <span style={{
|
|
position: "absolute", top: 3, left: 3, right: 3, bottom: 3,
|
|
background: "var(--accent)", borderRadius: "50%",
|
|
}}/>}
|
|
</span>
|
|
{(label || hint) && (
|
|
<span style={{ minWidth: 0 }}>
|
|
{label && <span style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</span>}
|
|
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
|
|
</span>
|
|
)}
|
|
</label>
|
|
);
|
|
|
|
// ─── Switch ──────────────────────────────────────────────────
|
|
const Switch = ({ checked, disabled, onChange = noop, label, hint, style }) => (
|
|
<label style={{
|
|
display: "flex", alignItems: "center", gap: 12,
|
|
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, ...style,
|
|
}}>
|
|
<span
|
|
role="switch"
|
|
aria-checked={!!checked}
|
|
onClick={() => !disabled && onChange(!checked)}
|
|
style={{
|
|
width: 34, height: 20, borderRadius: 999,
|
|
background: checked ? "var(--accent)" : "var(--surface-alt)",
|
|
border: `1px solid ${checked ? "var(--accent)" : "var(--border-strong)"}`,
|
|
position: "relative", flexShrink: 0,
|
|
transition: "background var(--duration), border-color var(--duration)",
|
|
}}
|
|
>
|
|
<span style={{
|
|
position: "absolute", top: 1, left: checked ? 15 : 1,
|
|
width: 16, height: 16, borderRadius: "50%",
|
|
background: checked ? "var(--text-on-accent)" : "var(--surface)",
|
|
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
|
|
transition: "left var(--duration) var(--ease)",
|
|
}}/>
|
|
</span>
|
|
{(label || hint) && (
|
|
<span style={{ flex: 1, minWidth: 0 }}>
|
|
{label && <div style={{ fontSize: "var(--text-md)", color: "var(--text)" }}>{label}</div>}
|
|
{hint && <div style={{ fontSize: "var(--text-xs)", color: "var(--text-3)", marginTop: 2 }}>{hint}</div>}
|
|
</span>
|
|
)}
|
|
</label>
|
|
);
|
|
|
|
// ─── Card / Surface ──────────────────────────────────────────
|
|
// Card paints a `surface` background with border + shadow.
|
|
// Use `variant="raised"` for shadow-lg, "flat" for no shadow.
|
|
const Card = ({ children, variant = "default", padding = 20, style, ...rest }) => {
|
|
const shadows = {
|
|
default: "var(--shadow-sm)",
|
|
raised: "var(--shadow)",
|
|
floating:"var(--shadow-lg)",
|
|
flat: "none",
|
|
};
|
|
return (
|
|
<div style={{
|
|
background: "var(--surface)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--card-radius)",
|
|
padding,
|
|
boxShadow: shadows[variant] || shadows.default,
|
|
backdropFilter: "blur(var(--surface-blur))",
|
|
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
|
color: "var(--text)",
|
|
...style,
|
|
}} {...rest}>{children}</div>
|
|
);
|
|
};
|
|
|
|
const CardHeader = ({ title, subtitle, action, style }) => (
|
|
<div style={{
|
|
display: "flex", justifyContent: "space-between", alignItems: "flex-start",
|
|
marginBottom: "var(--space-4)", gap: 16, ...style,
|
|
}}>
|
|
<div style={{ minWidth: 0 }}>
|
|
{title && <div style={{
|
|
fontSize: "var(--text-lg)", fontWeight: "var(--weight-semibold)",
|
|
color: "var(--text)", letterSpacing: "-0.01em",
|
|
}}>{title}</div>}
|
|
{subtitle && <div style={{
|
|
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: 2,
|
|
}}>{subtitle}</div>}
|
|
</div>
|
|
{action}
|
|
</div>
|
|
);
|
|
|
|
// ─── Badge / Tag ─────────────────────────────────────────────
|
|
// tone: neutral | accent | success | warn | danger | info
|
|
const Badge = ({ children, tone = "neutral", dot, leadingIcon, style }) => {
|
|
const palette = {
|
|
neutral: { bg: "var(--surface-alt)", fg: "var(--text-2)", dotColor: "var(--text-3)" },
|
|
accent: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
|
|
success: { bg: "var(--success-soft)", fg: "var(--success)", dotColor: "var(--success)" },
|
|
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", dotColor: "var(--warn)" },
|
|
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", dotColor: "var(--danger)" },
|
|
info: { bg: "var(--accent-soft)", fg: "var(--accent)", dotColor: "var(--accent)" },
|
|
}[tone] || {};
|
|
return (
|
|
<span style={{
|
|
display: "inline-flex", alignItems: "center", gap: 6,
|
|
padding: "2px 8px", borderRadius: "var(--radius-pill)",
|
|
background: palette.bg, color: palette.fg,
|
|
fontSize: "var(--text-xs)", fontWeight: "var(--weight-medium)",
|
|
whiteSpace: "nowrap", lineHeight: 1.4,
|
|
...style,
|
|
}}>
|
|
{dot && <span style={{ width: 6, height: 6, borderRadius: "50%", background: palette.dotColor }}/>}
|
|
{leadingIcon}
|
|
{children}
|
|
</span>
|
|
);
|
|
};
|
|
const Tag = Badge; // alias
|
|
|
|
// ─── Avatar ──────────────────────────────────────────────────
|
|
const avatarPalette = ["#d4b8a8", "#e8a87c", "#c8e8a8", "#a8c8e8", "#c8a8e8", "#e8c8a8", "#a8e8c8", "#e8a8c8"];
|
|
const hashName = (s = "") => {
|
|
let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
return avatarPalette[Math.abs(h) % avatarPalette.length];
|
|
};
|
|
const Avatar = ({ name = "?", src, size = 32, status, color, ring, style }) => {
|
|
const initials = name.split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join("").toUpperCase();
|
|
return (
|
|
<span style={{
|
|
position: "relative", display: "inline-flex", flexShrink: 0,
|
|
width: size, height: size, borderRadius: "50%",
|
|
background: color || hashName(name), color: "#3a2820",
|
|
alignItems: "center", justifyContent: "center",
|
|
fontSize: Math.round(size * 0.4), fontWeight: 600,
|
|
boxShadow: ring ? `0 0 0 2px var(--surface), 0 0 0 ${2 + ring}px var(--accent)` : "none",
|
|
overflow: "hidden", ...style,
|
|
}}>
|
|
{src
|
|
? <img src={src} alt={name} style={{ width: "100%", height: "100%", objectFit: "cover" }}/>
|
|
: initials}
|
|
{status && <span style={{
|
|
position: "absolute", bottom: 0, right: 0,
|
|
width: Math.max(8, size * 0.28), height: Math.max(8, size * 0.28),
|
|
borderRadius: "50%", border: "2px solid var(--surface)",
|
|
background: status === "online" ? "var(--success)" :
|
|
status === "busy" ? "var(--danger)" : "var(--text-3)",
|
|
}}/>}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const AvatarStack = ({ items = [], size = 28, max = 4 }) => {
|
|
const shown = items.slice(0, max);
|
|
const remaining = items.length - shown.length;
|
|
return (
|
|
<div style={{ display: "inline-flex" }}>
|
|
{shown.map((p, i) => (
|
|
<Avatar key={i} name={p.name} src={p.src} color={p.color} size={size}
|
|
style={{ marginLeft: i ? -size * 0.32 : 0, boxShadow: "0 0 0 2px var(--surface)" }}/>
|
|
))}
|
|
{remaining > 0 && (
|
|
<span style={{
|
|
width: size, height: size, borderRadius: "50%",
|
|
background: "var(--surface-alt)", color: "var(--text-2)",
|
|
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
fontSize: Math.round(size * 0.4), fontWeight: 600,
|
|
marginLeft: -size * 0.32, boxShadow: "0 0 0 2px var(--surface)",
|
|
}}>+{remaining}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Tabs ────────────────────────────────────────────────────
|
|
// Controlled: pass `active` (label of active tab) + `onChange`.
|
|
const Tabs = ({ items = [], active, onChange = noop, style, variant = "underline" }) => {
|
|
return (
|
|
<div style={{
|
|
display: "flex", gap: 4, borderBottom: variant === "underline" ? "1px solid var(--border)" : "none",
|
|
...style,
|
|
}}>
|
|
{items.map(t => {
|
|
const isActive = t.label === active || t.id === active;
|
|
if (variant === "pill") {
|
|
return (
|
|
<button key={t.id || t.label}
|
|
onClick={() => onChange(t.id || t.label)}
|
|
style={{
|
|
padding: "6px 12px", borderRadius: "var(--radius-pill)",
|
|
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)",
|
|
background: isActive ? "var(--accent)" : "transparent",
|
|
color: isActive ? "var(--text-on-accent)" : "var(--text-2)",
|
|
border: "none", cursor: "pointer", fontWeight: 500,
|
|
display: "inline-flex", alignItems: "center", gap: 6,
|
|
}}>
|
|
{t.icon}{t.label}
|
|
{t.count != null && (
|
|
<span style={{
|
|
fontSize: 10, padding: "1px 6px", borderRadius: 999,
|
|
background: isActive ? "rgba(255,255,255,0.18)" : "var(--surface-alt)",
|
|
color: "inherit",
|
|
}}>{t.count}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
return (
|
|
<button key={t.id || t.label}
|
|
onClick={() => onChange(t.id || t.label)}
|
|
style={{
|
|
padding: "10px 4px", margin: "0 12px 0 0",
|
|
fontFamily: "var(--font-sans)", fontSize: "var(--text-md)",
|
|
fontWeight: "var(--weight-medium)",
|
|
background: "transparent", border: "none", cursor: "pointer",
|
|
color: isActive ? "var(--text)" : "var(--text-2)",
|
|
borderBottom: isActive ? "2px solid var(--accent)" : "2px solid transparent",
|
|
position: "relative", top: 1,
|
|
display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap",
|
|
}}>
|
|
{t.icon}{t.label}
|
|
{t.count != null && (
|
|
<span style={{
|
|
fontSize: 10, padding: "1px 6px", borderRadius: 999,
|
|
background: isActive ? "var(--accent-soft)" : "var(--surface-alt)",
|
|
color: isActive ? "var(--accent)" : "var(--text-3)",
|
|
}}>{t.count}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Table ───────────────────────────────────────────────────
|
|
// columns: [{ key, label, width?, align?, render? }]
|
|
// rows: [{ id, [key]: value, … }]
|
|
const Table = ({ columns = [], rows = [], selectable, selected = [], onSelectionChange = noop, density = "comfortable" }) => {
|
|
const padY = density === "compact" ? 8 : 12;
|
|
const allChecked = rows.length > 0 && selected.length === rows.length;
|
|
const someChecked = selected.length > 0 && !allChecked;
|
|
const toggleAll = () => onSelectionChange(allChecked ? [] : rows.map(r => r.id));
|
|
const toggleOne = (id) => onSelectionChange(
|
|
selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]
|
|
);
|
|
|
|
const headerCell = {
|
|
padding: `10px 12px`, fontSize: "var(--text-xs)",
|
|
color: "var(--text-3)", fontWeight: "var(--weight-medium)",
|
|
textTransform: "uppercase", letterSpacing: "0.04em", textAlign: "left",
|
|
borderBottom: "1px solid var(--border)", background: "var(--surface)",
|
|
};
|
|
const bodyCell = {
|
|
padding: `${padY}px 12px`, fontSize: "var(--text-md)",
|
|
color: "var(--text)", borderBottom: "1px solid var(--divider)", verticalAlign: "middle",
|
|
};
|
|
|
|
return (
|
|
<div style={{
|
|
background: "var(--surface)", border: "1px solid var(--border)",
|
|
borderRadius: "var(--card-radius)", overflow: "hidden",
|
|
backdropFilter: "blur(var(--surface-blur))",
|
|
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
|
}}>
|
|
<table style={{ width: "100%", borderCollapse: "collapse", fontFamily: "var(--font-sans)" }}>
|
|
<thead>
|
|
<tr>
|
|
{selectable && (
|
|
<th style={{ ...headerCell, width: 36, paddingRight: 0 }}>
|
|
<Checkbox checked={allChecked} indeterminate={someChecked} onChange={toggleAll}/>
|
|
</th>
|
|
)}
|
|
{columns.map(c => (
|
|
<th key={c.key} style={{ ...headerCell, width: c.width, textAlign: c.align || "left" }}>
|
|
{c.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((r, i) => (
|
|
<tr key={r.id ?? i}>
|
|
{selectable && (
|
|
<td style={{ ...bodyCell, paddingRight: 0 }}>
|
|
<Checkbox checked={selected.includes(r.id)} onChange={() => toggleOne(r.id)}/>
|
|
</td>
|
|
)}
|
|
{columns.map(c => (
|
|
<td key={c.key} style={{ ...bodyCell, textAlign: c.align || "left" }}>
|
|
{c.render ? c.render(r) : r[c.key]}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Modal (presentational — wrap your own state) ────────────
|
|
const Modal = ({ open, onClose = noop, title, description, footer, children, width = 480 }) => {
|
|
if (!open) return null;
|
|
return (
|
|
<div
|
|
onClick={onClose}
|
|
style={{
|
|
position: "fixed", inset: 0, zIndex: 100,
|
|
background: "rgba(0,0,0,0.5)",
|
|
display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
|
|
}}
|
|
>
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
style={{
|
|
width, maxWidth: "100%", maxHeight: "85vh", overflow: "auto",
|
|
background: "var(--surface)", color: "var(--text)",
|
|
border: "1px solid var(--border)", borderRadius: "var(--modal-radius)",
|
|
boxShadow: "var(--shadow-modal)",
|
|
backdropFilter: "blur(var(--surface-blur))",
|
|
WebkitBackdropFilter: "blur(var(--surface-blur))",
|
|
}}
|
|
>
|
|
<div style={{
|
|
padding: "var(--space-6)", display: "flex",
|
|
justifyContent: "space-between", alignItems: "flex-start", gap: 16,
|
|
}}>
|
|
<div style={{ minWidth: 0 }}>
|
|
{title && <h2 style={{
|
|
margin: 0, fontSize: "var(--text-xl)", fontWeight: "var(--weight-semibold)",
|
|
letterSpacing: "-0.01em", fontFamily: "var(--font-display)",
|
|
}}>{title}</h2>}
|
|
{description && <p style={{
|
|
margin: "6px 0 0", fontSize: "var(--text-md)", color: "var(--text-2)",
|
|
}}>{description}</p>}
|
|
</div>
|
|
<IconButton name="x" size="sm" onClick={onClose} label="Close"/>
|
|
</div>
|
|
{children && <div style={{ padding: "0 var(--space-6) var(--space-6)" }}>{children}</div>}
|
|
{footer && (
|
|
<div style={{
|
|
padding: "var(--space-4) var(--space-6)",
|
|
borderTop: "1px solid var(--divider)",
|
|
display: "flex", justifyContent: "flex-end", gap: 8,
|
|
background: "var(--surface-2)",
|
|
}}>{footer}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Banner / Alert ──────────────────────────────────────────
|
|
const Banner = ({ tone = "info", title, children, action, onDismiss, icon, style }) => {
|
|
const palette = {
|
|
info: { bg: "var(--accent-soft)", fg: "var(--accent)", iconName: "info" },
|
|
success: { bg: "var(--success-soft)", fg: "var(--success)", iconName: "checkOnly" },
|
|
warn: { bg: "var(--warn-soft)", fg: "var(--warn)", iconName: "alert" },
|
|
danger: { bg: "var(--danger-soft)", fg: "var(--danger)", iconName: "alert" },
|
|
}[tone];
|
|
return (
|
|
<div style={{
|
|
display: "flex", gap: 12, padding: "12px 16px",
|
|
borderRadius: "var(--radius)",
|
|
background: palette.bg, color: "var(--text)",
|
|
border: `1px solid ${palette.fg}33`,
|
|
alignItems: "flex-start",
|
|
...style,
|
|
}}>
|
|
<span style={{ color: palette.fg, display: "flex", marginTop: 1 }}>
|
|
{icon ?? <Icon name={palette.iconName} size={16} stroke={2}/>}
|
|
</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
{title && <div style={{
|
|
fontSize: "var(--text-md)", fontWeight: "var(--weight-semibold)",
|
|
color: "var(--text)",
|
|
}}>{title}</div>}
|
|
{children && <div style={{
|
|
fontSize: "var(--text-sm)", color: "var(--text-2)", marginTop: title ? 2 : 0,
|
|
}}>{children}</div>}
|
|
</div>
|
|
{action}
|
|
{onDismiss && <IconButton name="x" size="sm" onClick={onDismiss} label="Dismiss"/>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Divider ─────────────────────────────────────────────────
|
|
const Divider = ({ label, vertical, style }) => {
|
|
if (vertical) return <span style={{ width: 1, alignSelf: "stretch", background: "var(--border)", ...style }}/>;
|
|
if (!label) return <hr style={{ border: "none", borderTop: "1px solid var(--border)", margin: "var(--space-4) 0", ...style }}/>;
|
|
return (
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 12,
|
|
fontSize: "var(--text-xs)", color: "var(--text-3)",
|
|
letterSpacing: "0.08em", textTransform: "uppercase",
|
|
margin: "var(--space-4) 0", ...style,
|
|
}}>
|
|
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
|
|
<span>{label}</span>
|
|
<div style={{ flex: 1, height: 1, background: "var(--border)" }}/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── FieldGroup — horizontal segmented control ───────────────
|
|
const FieldGroup = ({ options = [], value, onChange = noop, style }) => (
|
|
<div style={{
|
|
display: "inline-flex", padding: 3, gap: 2,
|
|
background: "var(--surface-alt)", border: "1px solid var(--border)",
|
|
borderRadius: "var(--radius)", ...style,
|
|
}}>
|
|
{options.map(o => {
|
|
const v = typeof o === "string" ? o : o.value;
|
|
const label = typeof o === "string" ? o : o.label;
|
|
const sel = v === value;
|
|
return (
|
|
<button key={v} onClick={() => onChange(v)} style={{
|
|
padding: "5px 12px", borderRadius: "calc(var(--radius) - 3px)",
|
|
fontFamily: "var(--font-sans)", fontSize: "var(--text-sm)", whiteSpace: "nowrap",
|
|
background: sel ? "var(--surface)" : "transparent",
|
|
color: sel ? "var(--text)" : "var(--text-2)",
|
|
border: "none", cursor: "pointer",
|
|
boxShadow: sel ? "var(--shadow-sm)" : "none",
|
|
fontWeight: sel ? 500 : 400,
|
|
}}>{label}</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
|
|
// ─── KBD ─────────────────────────────────────────────────────
|
|
const KBD = ({ children, style }) => (
|
|
<kbd style={{
|
|
fontFamily: "var(--font-mono)", fontSize: "var(--text-xs)",
|
|
padding: "1px 6px", borderRadius: 4,
|
|
background: "var(--surface-alt)", color: "var(--text-2)",
|
|
border: "1px solid var(--border)",
|
|
...style,
|
|
}}>{children}</kbd>
|
|
);
|
|
|
|
// ─── Exports ─────────────────────────────────────────────────
|
|
Object.assign(window, {
|
|
Button, IconButton, Spinner,
|
|
Field, Input, Textarea, Select, FieldGroup,
|
|
Checkbox, Radio, Switch,
|
|
Card, CardHeader, Divider,
|
|
Badge, Tag, Avatar, AvatarStack,
|
|
Tabs, Table, Modal, Banner, KBD,
|
|
});
|