planmymeal/components/base/buttons/button.tsx
Cayden Schwechten df5dd3a70e Move UI to BoardUI with slate + salmon theme; add PDF plan export
- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button,
  SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's
  color aliases onto BoardUI tokens and swap lucide for Remix icons
- Retint BoardUI's neutral ramp to cool slate with a salmon accent;
  drop the dotted background; raise muted text contrast
- Simplify the landing page and remove the overlap explorer
- Planner: explicit "I have this" toggle on grocery lines, pantry panel
  above the menu with the staples list spelled out, sticky aisle
  header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded)
- Recipes: replace the family cabbage soup with Cade's Cabbage Soup
  (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup
- Remove unused BoardUI components, dependencies and exports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:36:33 -06:00

217 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
ComponentType,
ReactNode,
Ref,
} from "react";
import { cx, sortCx } from "@/utils/cx";
import { directionalIconClass } from "@/utils/directional-icon";
/**
* Figma source: Board UI → Buttons (node 3656:13819).
*
* Variant matrix from Figma:
* Type = Primary | Secondary | Ghost | Danger
* Size = Medium | Small | Xs
* State = Default | Hover | Active | Disabled (CSS pseudo)
* OnlyIcon = false | true
*
* Sizing (1:1 with Figma):
*
* Medium Small Xs
* container h=36, p=8, r=10 h=32, px=8 py=6, r=8 h=24, px=8, r=4
* gap 2px 2px 1.33px→1
* icon 20×20 18×18 14×14
* label wrapper px=4 px=2 px=2
* text style Body 1/Medium Body 1/Medium Caption 1/Semibold
* icon-only square 36×36 (content-derived) 32×32 (forced size) 24×24 (forced size)
*
* `xs` is the smallest tier — first needed for the calendar template's
* event-details modal ("Join" / edit-icon buttons, node 3920:10954), which
* scales every dimension down by the same ~0.667 factor from Figma; the
* table above rounds those to clean pixel values rather than reproducing
* the fractional source numbers.
*
* Icons are rendered by the component itself via the `leadingIcon` /
* `trailingIcon` props so the consumer can't pass the wrong size. Pass
* a Remix Icon component reference (`RiAddLine`, not `<RiAddLine />`).
*
* For icon-only buttons:
* <Button iconOnly leadingIcon={RiAddLine} aria-label="Add" />
*
* The HTML `type` prop is preserved; Figma's "Type" enum is renamed to
* `variant` to avoid the clash.
*/
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
type ButtonSize = "medium" | "small" | "xs";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
variant?: ButtonVariant;
size?: ButtonSize;
iconOnly?: boolean;
leadingIcon?: IconComponent;
trailingIcon?: IconComponent;
children?: ReactNode;
ref?: Ref<HTMLButtonElement>;
}
export interface ButtonLinkProps
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "children"> {
variant?: ButtonVariant;
size?: ButtonSize;
iconOnly?: boolean;
leadingIcon?: IconComponent;
trailingIcon?: IconComponent;
children?: ReactNode;
ref?: Ref<HTMLAnchorElement>;
}
const styles = sortCx({
base: [
"inline-flex items-center justify-center gap-0.5 whitespace-nowrap overflow-hidden",
"font-sans select-none cursor-pointer",
"button-press-motion",
"outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-border-focus-ring",
"disabled:cursor-not-allowed aria-disabled:cursor-not-allowed",
].join(" "),
// Base shape per size (used when label is present OR medium icon-only).
size: {
medium: "h-9 rounded-2lg p-2 text-body-medium",
small: "h-8 rounded-lg px-2 py-1.5 text-body-medium",
xs: "h-6 rounded-sm px-2 text-caption-1-semibold",
},
// Icon-only override:
// Medium → keep p-2; w expands from content (8+20+8 = 36) → square.
// Small → Figma forces 32×32 even though 8+18+8=34, so we hard-set size-8
// and zero the padding; the inner flex centers the 18px icon.
// Xs → forces 24×24, content-centered — used for the calendar
// template's edit-icon buttons (timezone/participants/reminder).
iconOnlySize: {
medium: "", // base size already produces 36×36 with a 20px icon
small: "size-8 p-0", // hard 32×32, content-centered
xs: "size-6 p-0", // hard 24×24, content-centered
},
icon: {
medium: "size-5 shrink-0", // 20px
small: "size-[18px] shrink-0", // 18px
xs: "size-3.5 shrink-0", // 14px
},
label: {
medium: "inline-flex items-center justify-center px-1 shrink-0", // px=4
small: "inline-flex items-center justify-center px-0.5 shrink-0", // px=2
xs: "inline-flex items-center justify-center px-0.5 shrink-0", // px=2
},
variant: {
primary: [
"bg-button-primary text-text-white shadow-xs",
"disabled:text-button-primary-disabled-foreground disabled:shadow-none",
"aria-disabled:text-button-primary-disabled-foreground aria-disabled:shadow-none",
].join(" "),
danger: [
"bg-button-danger text-text-white shadow-xs",
"disabled:text-foreground-disabled-danger disabled:shadow-none",
"aria-disabled:text-foreground-disabled-danger aria-disabled:shadow-none",
].join(" "),
secondary: [
"bg-background-primary-default text-text-primary",
"border border-border-button-default shadow-xs",
"hover:bg-background-primary-hover hover:border-border-button-hover",
"active:bg-background-primary-active active:border-border-button-active",
"disabled:bg-background-primary-disabled disabled:border-border-button-default disabled:text-text-tertiary disabled:shadow-none",
"aria-disabled:bg-background-primary-disabled aria-disabled:border-border-button-default aria-disabled:text-text-tertiary aria-disabled:shadow-none",
].join(" "),
ghost: [
"bg-button-ghost-background text-button-ghost-foreground",
"hover:bg-button-ghost-hover active:bg-button-ghost-active",
"disabled:bg-button-ghost-disabled disabled:text-button-ghost-disabled-foreground disabled:shadow-none",
"aria-disabled:bg-button-ghost-disabled aria-disabled:text-button-ghost-disabled-foreground aria-disabled:shadow-none",
].join(" "),
},
});
export function Button({
variant = "primary",
size = "medium",
iconOnly = false,
leadingIcon: Leading,
trailingIcon: Trailing,
children,
className,
type = "button",
ref,
...props
}: ButtonProps) {
return (
<button
ref={ref}
type={type}
className={cx(
styles.base,
styles.size[size],
styles.variant[variant],
iconOnly && styles.iconOnlySize[size],
className,
)}
{...props}
>
{Leading ? <Leading className={cx(styles.icon[size], directionalIconClass(Leading))} aria-hidden /> : null}
{!iconOnly && children !== undefined && children !== null && (
<span className={styles.label[size]}>{children}</span>
)}
{!iconOnly && Trailing ? (
<Trailing className={cx(styles.icon[size], directionalIconClass(Trailing))} aria-hidden />
) : null}
</button>
);
}
/** Anchor counterpart to Button for navigational actions. */
export function ButtonLink({
variant = "primary",
size = "medium",
iconOnly = false,
leadingIcon: Leading,
trailingIcon: Trailing,
children,
className,
ref,
...props
}: ButtonLinkProps) {
return (
<a
ref={ref}
className={cx(
styles.base,
styles.size[size],
styles.variant[variant],
iconOnly && styles.iconOnlySize[size],
className,
)}
{...props}
>
{Leading ? <Leading className={cx(styles.icon[size], directionalIconClass(Leading))} aria-hidden /> : null}
{!iconOnly && children !== undefined && children !== null && (
<span className={styles.label[size]}>{children}</span>
)}
{!iconOnly && Trailing ? (
<Trailing className={cx(styles.icon[size], directionalIconClass(Trailing))} aria-hidden />
) : null}
</a>
);
}
/** Style maps, exported for advanced composition and the dev Design Tuner. */
export const buttonStyles = styles;