import { getCardColors, isPrefixedHexColor, isValidGradient } from "./color.js";
import type { CardColors } from "./color.js";
import { encodeHTML } from "./html.js";
import { flexLayout } from "./render.js";
/**
* Builds the card CSS for one color scheme from that scheme's resolved colors.
*/
type CardCSSBuilder = (colors: CardColors) => string;
class Card {
width: number;
height: number;
hideBorder: boolean;
hideTitle: boolean;
border_radius: number;
colors: { light: CardColors; dark: CardColors | null };
title: string;
css: string;
darkCss: string;
paddingX: number;
paddingY: number;
titlePrefixIcon: string | undefined;
animations: boolean;
a11yTitle: string;
a11yDesc: string;
/**
* Creates a new card instance.
*
* The caller must ensure that the passed `titlePrefixIcon` is properly sanitized!
*
* @param props Card arguments.
* @param props.width Card width.
* @param props.height Card height.
* @param props.border_radius Card border radius.
* @param props.colors Card colors for light and dark mode.
* @param props.colors.light Card colors for light mode.
* @param props.colors.dark Card colors for dark mode, or `null` when no dark-mode override is needed.
* @param props.customTitle Card custom title.
* @param props.defaultTitle Card default title.
* @param props.titlePrefixIcon Sanitized card title prefix icon.
*/
constructor({
width = 100,
height = 100,
border_radius = 4.5,
colors = { light: getCardColors({}), dark: null },
customTitle,
defaultTitle = "",
titlePrefixIcon,
}: {
width?: number;
height?: number;
// `| undefined`: card callers forward possibly-undefined query options
border_radius?: number | undefined;
colors?: { light: CardColors; dark: CardColors | null };
customTitle?: string | undefined;
defaultTitle?: string;
titlePrefixIcon?: string;
}) {
this.width = width;
this.height = height;
this.hideBorder = false;
this.hideTitle = false;
this.border_radius = parseFloat(String(border_radius));
this.colors = colors;
this.title = customTitle === undefined ? defaultTitle : customTitle;
this.css = "";
this.darkCss = "";
this.paddingX = 25;
this.paddingY = 35;
this.titlePrefixIcon = titlePrefixIcon;
this.animations = true;
this.a11yTitle = "";
this.a11yDesc = "";
}
disableAnimations(): void {
this.animations = false;
}
/**
* @param props The props object.
* @param props.title Accessibility title.
* @param props.desc Accessibility description.
*/
setAccessibilityLabel({
title,
desc,
}: {
title: string;
desc: string;
}): void {
this.a11yTitle = title;
this.a11yDesc = desc;
}
/**
* Sets the card CSS for light and dark mode.
*
* Each builder receives the colors of its own color scheme,
* so a card never handles a missing dark palette itself:
* `dark` runs only when the card has dark colors, and always with non-null ones.
*
* @warning The caller must ensure that the returned CSS strings are properly sanitized!
*
* @param props The props object.
* @param props.light Builds the CSS applied unconditionally (light/default mode).
* @param props.dark Builds the CSS placed inside a `@media (prefers-color-scheme: dark)` block.
*/
setCSS({
light,
dark,
}: {
light: CardCSSBuilder;
dark: CardCSSBuilder;
}): void {
this.css = light(this.colors.light);
this.darkCss = this.colors.dark ? dark(this.colors.dark) : "";
}
/**
* @param value Whether to hide the border or not.
*/
setHideBorder(value: boolean): void {
this.hideBorder = value;
}
/**
* @param value Whether to hide the title or not.
*/
setHideTitle(value: boolean): void {
if (value && !this.hideTitle) {
this.height -= 30;
}
if (!value && this.hideTitle) {
this.height += 30;
}
this.hideTitle = value;
}
/**
* @param text The title to set.
*/
setTitle(text: string): void {
this.title = text;
}
/**
* @returns The rendered card title.
*/
renderTitle(): string {
const titleText = `
`;
const prefixIcon = `
`;
return `
${flexLayout({
items: [this.titlePrefixIcon ? prefixIcon : "", titleText],
gap: 25,
}).join("")}
`;
}
/**
* @returns The rendered card gradient.
*/
renderGradient(): string {
const buildGradientDef = (id: string, bgColor: Array): string => {
const gradients = bgColor.slice(1);
return `
${gradients
.map((grad, index) => {
const offset = (index * 100) / (gradients.length - 1);
return ``;
})
.join(",")}
`;
};
if (
typeof this.colors.light.bgColor === "object" &&
!isValidGradient(this.colors.light.bgColor)
) {
throw new Error(
`Invalid gradient: ${this.colors.light.bgColor.join(",")}`,
);
}
if (
this.colors.dark &&
typeof this.colors.dark.bgColor === "object" &&
!isValidGradient(this.colors.dark.bgColor)
) {
throw new Error(
`Invalid dark gradient: ${this.colors.dark.bgColor.join(",")}`,
);
}
return `
${typeof this.colors.light.bgColor === "object" ? buildGradientDef("gradient", this.colors.light.bgColor) : ""}
${this.colors.dark && typeof this.colors.dark.bgColor === "object" ? buildGradientDef("gradient-dark", this.colors.dark.bgColor) : ""}
`;
}
/**
* Retrieves css animations for a card.
*
* @returns Animation css.
*/
getAnimations = (): string => {
return `
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
};
/**
* Builds the @media (prefers-color-scheme: dark) CSS block for the card.
* Returns an empty string when no dark colors are set.
*/
private renderDarkMediaBlock(): string {
if (!this.colors.dark) {
return "";
}
const bgFill =
typeof this.colors.dark.bgColor === "object"
? "url(#gradient-dark)"
: this.colors.dark.bgColor;
return `
@media (prefers-color-scheme: dark) {
.header { fill: ${this.colors.dark.titleColor}; }
.card-bg { fill: ${bgFill}; stroke: ${this.colors.dark.borderColor}; }
${this.darkCss}
}`;
}
/**
* The caller must ensure that the passed `body` string is properly sanitized!
*
* @param body The sanitized inner body of the card.
* @returns The rendered card.
*/
render(body: string): string {
if (!Number.isFinite(this.border_radius)) {
throw new Error(`Invalid border radius: "${this.border_radius}"`);
}
if (!isPrefixedHexColor(this.colors.light.titleColor)) {
throw new Error(`Invalid title color: "${this.colors.light.titleColor}"`);
}
if (!isPrefixedHexColor(this.colors.light.borderColor)) {
throw new Error(
`Invalid border color: "${this.colors.light.borderColor}"`,
);
}
if (
!(typeof this.colors.light.bgColor === "object"
? isValidGradient(this.colors.light.bgColor)
: isPrefixedHexColor(this.colors.light.bgColor))
) {
throw new Error(
`Invalid background color: ${String(this.colors.light.bgColor)}`,
);
}
return `
`;
}
}
export { Card };
export default Card;