planmymeal/components/base/switch/switch.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

172 lines
6.4 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.

"use client";
import type { ReactNode, Ref } from "react";
import { Switch as AriaSwitch } from "react-aria-components";
import type { SwitchProps as AriaSwitchProps } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";
/**
* Figma source: Board UI → "Toogles" (node 3856:3807).
*
* Two shapes × three sizes, plus off / on:
* shape pill (Figma "fully rounded") — track & thumb rounded-full
* rectangle (Figma "rectangle") — track radius/md-scale, thumb radius/xs-scale
* size sm 28×16 · md 42×24 · lg 56×32 (track w×h; thumb = h − 2·pad)
*
* Anatomy (all values 1:1 with Figma):
* track off → bg background/tertiary/default (#ebebeb, no border)
* on → Button/Primary gradient (blue/500 → blue/600) + an inset
* ring + top highlight that scales with size. lg is exactly
* the shared `--shadow-checkbox-selected` token; md/sm scale
* the ring (0.75px / 0.5px) and highlight (1.5px / 1px).
* thumb theme-stable white gradient + contact shadow,
* slides by (w − thumb − 2·pad) between states.
* chip small embossed inset in the thumb centre:
* off → white→neutral-100 in light mode; in dark mode it matches
* the track so the mark reads like a hole through the thumb;
* on → switch-on-chip-start→end (accent mixes matching Figma's
* raw #2473fe→#0450e2 for blue), accent/600 border.
*
* The track visual lives in `SwitchTrack` so `Switch` and `SwitchCard` share it
* (mirrors how Checkbox / CheckboxCard share `CheckboxGlyph`). Built on
* react-aria Switch: native toggle semantics, keyboard support, focus-visible
* ring, optional label via `children`.
*/
export type SwitchSize = "sm" | "md" | "lg";
export type SwitchShape = "pill" | "rectangle";
export const switchSizes = sortCx({
sm: {
track: "h-4 w-7",
trackRadius: { pill: "rounded-full", rectangle: "rounded-[3px]" },
onShadow: "shadow-[inset_0_1px_0_0_rgb(255_255_255/0.25),inset_0_0_0_0.5px_var(--color-accent-500)]",
thumb: "size-3",
thumbRadius: { pill: "rounded-full", rectangle: "rounded-[1px]" },
offset: "start-0.5 top-0.5",
travel: "translate-x-3 rtl:-translate-x-3",
chip: "size-[5px] border-[0.25px] shadow-[0_2px_2px_0_rgb(0_0_0/0.03)]",
chipRadius: { pill: "rounded-full", rectangle: "rounded-[0.5px]" },
},
md: {
track: "h-6 w-[42px]",
trackRadius: { pill: "rounded-full", rectangle: "rounded-[4.5px]" },
onShadow: "shadow-[inset_0_1.5px_0_0_rgb(255_255_255/0.25),inset_0_0_0_0.75px_var(--color-accent-500)]",
thumb: "size-[18px]",
thumbRadius: { pill: "rounded-full", rectangle: "rounded-[1.5px]" },
offset: "start-[3px] top-[3px]",
travel: "translate-x-[18px] rtl:-translate-x-[18px]",
chip: "size-[7.5px] border-[0.375px] shadow-[0_3px_3px_0_rgb(0_0_0/0.03)]",
chipRadius: { pill: "rounded-full", rectangle: "rounded-[0.75px]" },
},
lg: {
track: "h-8 w-14",
trackRadius: { pill: "rounded-full", rectangle: "rounded-md" },
onShadow: "shadow-checkbox-selected",
thumb: "size-6",
thumbRadius: { pill: "rounded-full", rectangle: "rounded-xs" },
offset: "start-1 top-1",
travel: "translate-x-6 rtl:-translate-x-6",
chip: "size-[10px] border-[0.5px] shadow-[0_4px_4px_0_rgb(0_0_0/0.03)]",
chipRadius: { pill: "rounded-full", rectangle: "rounded-[1px]" },
},
});
export interface SwitchVisualState {
isSelected: boolean;
isDisabled: boolean;
isFocusVisible: boolean;
}
/**
* The track + thumb + chip visual. Pure presentation — the react-aria Switch
* (in `Switch` or `SwitchCard`) owns the input and passes its render state in.
*/
export function SwitchTrack({
state,
size = "md",
shape = "pill",
}: {
state: SwitchVisualState;
size?: SwitchSize;
shape?: SwitchShape;
}) {
const s = switchSizes[size];
return (
<span
aria-hidden
className={cx(
"relative shrink-0 transition-colors duration-200 ease",
s.track,
s.trackRadius[shape],
state.isSelected
? cx("bg-linear-to-b from-accent-500 to-accent-600", s.onShadow)
: "bg-background-tertiary-default",
state.isDisabled && "opacity-50",
state.isFocusVisible && "ring-2 ring-border-focus-ring ring-offset-2",
)}
>
{/* Thumb */}
<span
className={cx(
"absolute flex items-center justify-center",
"bg-linear-to-b from-control-indicator-background from-[43.837%] to-control-indicator-background-subtle",
"shadow-[0_3px_3px_0_rgb(0_0_0/0.03),0_0.75px_0_0_rgb(0_0_0/0.05)]",
"transition-transform duration-200 ease",
s.thumb,
s.thumbRadius[shape],
s.offset,
state.isSelected && s.travel,
)}
>
{/* Embossed inner chip. Gradient runs bottom→top (`to-t`) — equivalent
to the Figma layer's vertical flip — but WITHOUT a transform, so the
chip stays pixel-snapped/centred (no blurry 1px shift at sub-pixel
sizes) and its drop shadow points down instead of up. */}
<span
className={cx(
"border-solid bg-linear-to-t from-[43.837%]",
s.chip,
s.chipRadius[shape],
state.isSelected
? "border-accent-600 from-switch-on-chip-start to-switch-on-chip-end"
: "border-border-button-default/50 from-switch-off-chip-start to-switch-off-chip-end",
)}
/>
</span>
</span>
);
}
export interface SwitchProps extends Omit<AriaSwitchProps, "children"> {
children?: ReactNode;
size?: SwitchSize;
shape?: SwitchShape;
ref?: Ref<HTMLLabelElement>;
}
export function Switch({ className, children, size = "md", shape = "pill", ref, ...props }: SwitchProps) {
return (
<AriaSwitch
ref={ref}
{...props}
className={(state) =>
cx(
"group inline-flex items-center gap-2 select-none",
state.isDisabled ? "cursor-not-allowed" : "cursor-pointer",
typeof className === "function" ? className(state) : className,
)
}
>
{(state) => (
<>
<SwitchTrack state={state} size={size} shape={shape} />
{children != null && children !== false && (
<span className="text-body-medium text-text-primary">{children}</span>
)}
</>
)}
</AriaSwitch>
);
}