- 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>
217 lines
7.7 KiB
TypeScript
217 lines
7.7 KiB
TypeScript
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;
|