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>
This commit is contained in:
parent
b02c7a024b
commit
df5dd3a70e
56 changed files with 4749 additions and 788 deletions
148
components/base/segmented-control/segmented-control.tsx
Normal file
148
components/base/segmented-control/segmented-control.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { useDirection } from "@/components/foundations/direction/direction";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import {
|
||||
ToggleButton as AriaToggleButton,
|
||||
ToggleButtonGroup as AriaToggleButtonGroup,
|
||||
} from "react-aria-components";
|
||||
import type {
|
||||
ToggleButtonGroupProps as AriaToggleButtonGroupProps,
|
||||
ToggleButtonProps as AriaToggleButtonProps,
|
||||
} from "react-aria-components";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
||||
|
||||
/**
|
||||
* Figma source: Board UI → dashboard 1 Weekly/Monthly/Yearly switch
|
||||
* (node 3731:3096).
|
||||
*
|
||||
* Single-select segmented control.
|
||||
* track bg background/tertiary/default, p 4, radius 10 (radius/2lg), gap 2
|
||||
* segment px 10, py 4, radius/md (6px)
|
||||
* selected bg segmented-control/selected (white in light), shadow/2xs, Body 1/Medium,
|
||||
* text/primary
|
||||
* unselected transparent, Body 1/Regular, text/secondary
|
||||
*
|
||||
* Built on react-aria ToggleButtonGroup with single selection that can't be
|
||||
* emptied — arrow keys move focus, Space/Enter selects.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `solid` (default) is the Figma design — tertiary track, white thumb behind
|
||||
* the selected segment. `plain` drops the track background and the thumb; the
|
||||
* selected segment reads only through its text weight/color.
|
||||
*/
|
||||
export type SegmentedControlVariant = "solid" | "plain";
|
||||
|
||||
export interface SegmentedControlProps
|
||||
extends Omit<AriaToggleButtonGroupProps, "selectionMode" | "disallowEmptySelection"> {
|
||||
children?: ReactNode;
|
||||
variant?: SegmentedControlVariant;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
type Thumb = { left: number; top: number; width: number; height: number };
|
||||
|
||||
export function SegmentedControl({ className, children, variant = "solid", ref, ...props }: SegmentedControlProps) {
|
||||
const direction = useDirection();
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const [thumb, setThumb] = useState<Thumb | null>(null);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const el = innerRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const selected = el.querySelector<HTMLElement>("[data-selected]");
|
||||
if (selected) {
|
||||
setThumb({
|
||||
left: selected.offsetLeft,
|
||||
top: selected.offsetTop,
|
||||
width: selected.offsetWidth,
|
||||
height: selected.offsetHeight,
|
||||
});
|
||||
}
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
const observeItems = () => el.querySelectorAll("button").forEach(item => ro.observe(item));
|
||||
observeItems();
|
||||
const mo = new MutationObserver(() => { observeItems(); measure(); });
|
||||
mo.observe(el, { attributes: true, childList: true, characterData: true, subtree: true, attributeFilter: ["data-selected"] });
|
||||
return () => {
|
||||
mo.disconnect();
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [direction]);
|
||||
|
||||
const setRefs = (node: HTMLDivElement | null) => {
|
||||
innerRef.current = node;
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
|
||||
};
|
||||
|
||||
return (
|
||||
<AriaToggleButtonGroup
|
||||
ref={setRefs}
|
||||
selectionMode="single"
|
||||
disallowEmptySelection
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
"relative inline-flex items-start gap-0.5 rounded-2lg",
|
||||
variant === "solid" && "bg-segmented-control-background p-1",
|
||||
typeof className === "function" ? className(state) : className,
|
||||
)
|
||||
}
|
||||
>
|
||||
{variant === "solid" && thumb && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-0 top-0 rounded-md bg-segmented-control-selected-background shadow-2xs transition-[transform,width,height] duration-200 ease"
|
||||
style={{
|
||||
transform: `translate(${thumb.left}px, ${thumb.top}px)`,
|
||||
width: thumb.width,
|
||||
height: thumb.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</AriaToggleButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SegmentedControlItemProps extends Omit<AriaToggleButtonProps, "children"> {
|
||||
children?: ReactNode;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export function SegmentedControlItem({
|
||||
className,
|
||||
children,
|
||||
ref,
|
||||
...props
|
||||
}: SegmentedControlItemProps) {
|
||||
return (
|
||||
<AriaToggleButton
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
"relative z-10 inline-flex cursor-pointer items-center justify-center rounded-md px-2.5 py-1 text-center whitespace-nowrap",
|
||||
"transition-colors duration-200 ease",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
|
||||
state.isSelected
|
||||
? "text-body-medium text-text-primary"
|
||||
: "text-body-regular text-text-secondary hover:text-text-primary",
|
||||
state.isDisabled && "cursor-not-allowed opacity-50",
|
||||
typeof className === "function" ? className(state) : className,
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AriaToggleButton>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue