196 lines
8.2 KiB
TypeScript
196 lines
8.2 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> = {
|
|
// The one button that INVERTS with the theme: dark on a light page, light on a
|
|
// dark one. It cannot be `bg-ink` with `text-white`, because the moment the fill
|
|
// goes pale in dark mode the white label vanishes — the fill and its text have to
|
|
// move together, which is what the three fill-neutral tokens are for.
|
|
"neutral": "shadow-xs bg-fill-neutral text-on-fill-neutral hover:bg-fill-neutral-hover",
|
|
"white": "shadow-xs bg-surface text-ink border border-line-strong hover:bg-surface-muted",
|
|
"light-neutral": "shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised",
|
|
"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-surface-raised text-ink border border-line-strong hover:bg-surface-strong",
|
|
"ghost": "shadow-none bg-transparent text-ink-soft border-none hover:bg-surface-raised",
|
|
};
|
|
|
|
const OUTLINE_COLORS: Record<string, string> = {
|
|
"neutral": "text-ink",
|
|
"white": "text-ink-faint",
|
|
"light-neutral": "text-ink-faint",
|
|
"blue": "text-sky-700 dark:text-sky-400",
|
|
"dark-blue": "text-sky-900",
|
|
"green": "text-green-700 dark:text-green-400",
|
|
"dark-green": "text-green-900",
|
|
"red": "text-red-700 dark:text-red-400",
|
|
"dark-red": "text-red-900",
|
|
"yellow": "text-yellow-700 dark:text-yellow-400",
|
|
"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 dark:text-sky-400 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 dark:text-red-400 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-surface text-ink shadow-sm";
|
|
const inactiveCls = "text-ink-muted hover:text-ink";
|
|
|
|
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-surface-raised 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-ink-soft hover:text-ink");
|
|
return (
|
|
<a href={props.href} class={cls()}>
|
|
<Icon icon="chevron-left" size={16}/>
|
|
{props.text}
|
|
</a>
|
|
);
|
|
}
|