Files
kjol/web/kit/Buttons.tsx

192 lines
7.8 KiB
TypeScript

import { JSX, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
export const BUTTON_COLOR_NEUTRAL = "neutral";
export const BUTTON_COLOR_WHITE = "white";
export const BUTTON_COLOR_LIGHT_NEUTRAL = "light-neutral";
export const BUTTON_COLOR_BLUE = "blue";
export const BUTTON_COLOR_DARK_BLUE = "dark-blue";
export const BUTTON_COLOR_GREEN = "green";
export const BUTTON_COLOR_DARK_GREEN = "dark-green";
export const BUTTON_COLOR_RED = "red";
export const BUTTON_COLOR_DARK_RED = "dark-red";
export const BUTTON_COLOR_YELLOW = "yellow";
export const BUTTON_COLOR_ORANGE = "orange";
export const BUTTON_COLOR_PRIMARY = "primary";
const BASE = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2";
const COLORS: Record<string, string> = {
"neutral": "shadow-xs bg-neutral-700 text-white hover:bg-neutral-800",
"white": "shadow-xs bg-white text-black border border-neutral-300 hover:bg-neutral-50",
"light-neutral": "shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100",
"blue": "shadow-xs bg-sky-700 text-white hover:bg-sky-800",
"dark-blue": "shadow-xs bg-sky-900 text-white hover:bg-sky-950",
"green": "shadow-xs bg-green-700 text-white hover:bg-green-800",
"dark-green": "shadow-xs bg-green-900 text-white hover:bg-green-950",
"red": "shadow-xs bg-red-700 text-white hover:bg-red-800",
"dark-red": "shadow-xs bg-red-900 text-white hover:bg-red-950",
"yellow": "shadow-xs bg-yellow-700 text-white hover:bg-yellow-800",
"orange": "shadow-xs bg-orange-600 text-white hover:bg-orange-700",
"primary": "shadow-xs bg-primary text-white hover:bg-primary-hover",
"secondary": "shadow-none bg-neutral-100 text-neutral-700 border border-neutral-300 hover:bg-neutral-200",
"ghost": "shadow-none bg-transparent text-neutral-600 border-none hover:bg-neutral-100",
};
const OUTLINE_COLORS: Record<string, string> = {
"neutral": "text-neutral-700",
"white": "text-neutral-300",
"light-neutral": "text-neutral-300",
"blue": "text-sky-700",
"dark-blue": "text-sky-900",
"green": "text-green-700",
"dark-green": "text-green-900",
"red": "text-red-700",
"dark-red": "text-red-900",
"yellow": "text-yellow-700",
"orange": "text-orange-600",
"primary": "text-primary",
};
const OUTLINE_BASE = "bg-transparent shadow-[inset_0_0_0_1px_currentColor] hover:shadow-[inset_0_0_0_2px_currentColor]";
interface ButtonUIProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
text?: string;
icon?: unknown;
outline?: boolean;
color?: string;
small?: boolean;
}
export function ButtonUI(props: ButtonUIProps) {
const hasText = () => props.text !== undefined ? !!props.text : !props.icon;
const cls = () => {
let c = BASE;
if (props.outline) {
c += " " + OUTLINE_BASE + " " + (OUTLINE_COLORS[props.color!] || OUTLINE_COLORS["neutral"]);
} else {
c += " " + (COLORS[props.color!] || COLORS["neutral"]);
}
if (props.small) {
c += props.icon ? " py-1 px-3" : " py-1 px-4";
} else if (props.icon && !hasText()) {
c += " py-2 px-3";
} else if (props.icon) {
c += " py-2 px-5";
} else {
c += " py-2 px-8";
}
return c;
};
return (
<button
type={props.type || "button"}
onclick={(e) => typeof props.onclick === "function" && props.onclick(e)}
onmousedown={(e) => typeof props.onmousedown === "function" && props.onmousedown(e)}
disabled={props.disabled}
title={props.title}
class={cls()}
>
{props.children}
</button>
);
}
interface ButtonLinkProps {
onclick?: (e: MouseEvent) => void;
children?: JSXElement;
}
export function ButtonLink(props: ButtonLinkProps) {
return (
<button type="button" onclick={(e: MouseEvent) => typeof props.onclick === "function" && props.onclick(e)} class="cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 hover:underline">{props.children}</button>
);
}
export function ButtonLinkRed(props: ButtonLinkProps) {
return (
<button type="button" onclick={(e: MouseEvent) => typeof props.onclick === "function" && props.onclick(e)} class="cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 hover:underline">{props.children}</button>
);
}
// SegmentedButtons renders a horizontal group of mutually-exclusive button
// options - one is "selected" at any time. Used for "toggle" patterns like
// the events sidebar's date/class switcher. Pill styling: a neutral track
// holds a raised white chip that marks the selected option; the rest sit
// muted. Buttons stretch to fill the track (flex-1).
type Reactive2<T> = T | (() => T);
export interface SegmentedButtonOption {
value: string;
label: string;
icon?: string;
}
interface SegmentedButtonsProps {
options: Reactive2<SegmentedButtonOption[]>;
value: Reactive2<string>;
onchange: (v: string) => void;
small?: boolean;
class?: string;
}
export function SegmentedButtons(props: SegmentedButtonsProps) {
const resolveR = <T,>(v: Reactive2<T>): T => (typeof v === "function" ? (v as () => T)() : v);
// Concentric corner radii: outer = inner + gap, where the gap is the
// track's p-0.5 (2px) padding -> 6px = 4px + 2px. Reusing one radius for
// both makes the chip corners read as too sharp inside the track.
const innerRadius = "rounded-default"; // chips: 4px
const outerRadius = "rounded-md"; // track: 4px + 2px = 6px
const sizeCls = () => props.small ? "py-0.5 px-2 text-xs" : "py-1 px-3 text-sm";
const baseCls = "inline-flex items-center justify-center gap-1.5 flex-1 cursor-pointer font-medium transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed";
const activeCls = "bg-white text-text-heading shadow-sm";
const inactiveCls = "text-neutral-500 hover:text-neutral-700";
const buttonCls = (v: string) => {
const selected = resolveR(props.value) === v;
return baseCls + " " + innerRadius + " " + sizeCls() + " " + (selected ? activeCls : inactiveCls);
};
return (
<div class={"flex items-center gap-0.5 " + outerRadius + " bg-neutral-100 p-0.5 " + (props.class || "")}>
{resolveR(props.options).map((opt) => (
<button
type="button"
class={buttonCls(opt.value)}
onclick={() => props.onchange(opt.value)}
title={opt.label}
>
{opt.icon ? <Icon icon={opt.icon} size={12} /> : ""}
<span>{opt.label}</span>
</button>
))}
</div>
);
}
interface BackLinkProps {
href: string;
text?: string;
onDark?: boolean;
}
// Bare inline-flex anchor — callers control surrounding spacing so
// the link can sit cleanly inside a flex row without throwing off
// vertical alignment (e.g. inside a page header next to a title).
export function BackLink(props: BackLinkProps) {
const cls = () => "inline-flex items-center gap-1 text-sm no-underline "
+ (props.onDark
? "text-text-on-dark-muted hover:text-text-on-dark"
: "text-neutral-600 hover:text-neutral-900");
return (
<a href={props.href} class={cls()}>
<Icon icon="chevron-left" size={16}/>
{props.text}
</a>
);
}