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
217
components/base/buttons/button.tsx
Normal file
217
components/base/buttons/button.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
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;
|
||||
70
components/base/input/label.tsx
Normal file
70
components/base/input/label.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { RiInformationFill } from "@remixicon/react";
|
||||
import type { LabelProps as AriaLabelProps } from "react-aria-components";
|
||||
import { Label as AriaLabel } from "react-aria-components";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
/**
|
||||
* Field label. Wraps `react-aria-components`' Label so the parent `TextField`
|
||||
* automatically wires `htmlFor` / `aria-labelledby`.
|
||||
*
|
||||
* Visuals come from Figma:
|
||||
* - Inter Medium 14/20, color text/primary
|
||||
* - 2px gap between label + required asterisk + info icon
|
||||
* - asterisk is text/error-primary (red/500)
|
||||
* - info icon is 16×16 in foreground/icon/quaternary
|
||||
*
|
||||
* Pass `isRequired` to show the asterisk. Pass `tooltip` to show the info
|
||||
* icon (currently a static glyph — when the Tooltip component lands it'll
|
||||
* wire up here).
|
||||
*/
|
||||
|
||||
export interface LabelProps extends AriaLabelProps {
|
||||
children: ReactNode;
|
||||
isRequired?: boolean;
|
||||
/** Reserved for invalid-aware styling (asterisk color, etc.) once we need it. */
|
||||
isInvalid?: boolean;
|
||||
/** Show the info icon next to the label. */
|
||||
tooltip?: boolean | string;
|
||||
ref?: Ref<HTMLLabelElement>;
|
||||
}
|
||||
|
||||
export function Label({
|
||||
isRequired = false,
|
||||
isInvalid: _isInvalid,
|
||||
tooltip,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: LabelProps) {
|
||||
void _isInvalid;
|
||||
return (
|
||||
<AriaLabel
|
||||
data-label="true"
|
||||
{...props}
|
||||
className={cx(
|
||||
"flex cursor-default items-center gap-0.5",
|
||||
"text-body-medium text-text-primary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{isRequired && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-body-medium text-text-error-primary"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
{tooltip && (
|
||||
<RiInformationFill
|
||||
className="size-4 shrink-0 text-foreground-icon-quaternary"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</AriaLabel>
|
||||
);
|
||||
}
|
||||
74
components/base/meter/meter.tsx
Normal file
74
components/base/meter/meter.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Meter as AriaMeter } from "react-aria-components";
|
||||
import type { MeterProps as AriaMeterProps } from "react-aria-components";
|
||||
import { Label } from "@/components/base/input/label";
|
||||
import { ProgressTrack, type ProgressSize } from "@/components/base/progress/progress";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
|
||||
/**
|
||||
* Meter — a quantity within a known range: storage used, seats taken, a
|
||||
* password's strength. Unlike ProgressBar it describes a level rather than
|
||||
* a task moving towards done, and React Aria's Meter announces it that way
|
||||
* (role="meter").
|
||||
*
|
||||
* <Meter label="Storage" value={72} valueLabel="7.2 of 10 GB" />
|
||||
* <Meter label="Seats" value={19} maxValue={20} tone="warning" />
|
||||
*
|
||||
* It is drawn on ProgressBar's track: the same rail, fill, and eased
|
||||
* translate when the value changes. `tone` colours the fill from semantic
|
||||
* tokens, so a meter can turn amber or red as it fills up.
|
||||
*/
|
||||
|
||||
export type MeterTone = "accent" | "success" | "warning" | "error";
|
||||
|
||||
const tones = sortCx({
|
||||
accent: "text-accent-500",
|
||||
success: "text-notification-success-foreground",
|
||||
warning: "text-chart-8-active",
|
||||
error: "text-notification-error-foreground",
|
||||
});
|
||||
|
||||
export interface MeterProps extends Omit<AriaMeterProps, "children" | "className"> {
|
||||
label?: ReactNode;
|
||||
/** Show the formatted value at the end of the label row. Default: shown with a label. */
|
||||
showValueLabel?: boolean;
|
||||
/** Helper text under the bar. */
|
||||
description?: ReactNode;
|
||||
/** Colour of the fill. Default `accent`. */
|
||||
tone?: MeterTone;
|
||||
/** Track thickness: 4, 6 (default), or 8px. */
|
||||
size?: ProgressSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Meter({
|
||||
label,
|
||||
showValueLabel,
|
||||
description,
|
||||
tone = "accent",
|
||||
size = "md",
|
||||
className,
|
||||
...props
|
||||
}: MeterProps) {
|
||||
const showValue = showValueLabel ?? label != null;
|
||||
return (
|
||||
<AriaMeter {...props} className={cx("flex w-full flex-col gap-1.5", className)}>
|
||||
{({ percentage, valueText }) => (
|
||||
<>
|
||||
{label != null || showValue ? (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
{label != null ? <Label className="min-w-0 truncate">{label}</Label> : <span />}
|
||||
{showValue ? (
|
||||
<span className="shrink-0 text-body-medium text-text-secondary tabular-nums">{valueText}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<ProgressTrack percentage={percentage} size={size} className={tones[tone]} />
|
||||
{description ? <span className="text-caption-1-medium text-text-secondary">{description}</span> : null}
|
||||
</>
|
||||
)}
|
||||
</AriaMeter>
|
||||
);
|
||||
}
|
||||
197
components/base/progress/progress.tsx
Normal file
197
components/base/progress/progress.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ProgressBar as AriaProgressBar } from "react-aria-components";
|
||||
import type { ProgressBarProps as AriaProgressBarProps } from "react-aria-components";
|
||||
import { Label } from "@/components/base/input/label";
|
||||
import { SpinnerGlyph } from "@/components/base/spinner/spinner";
|
||||
import { useDirection } from "@/components/foundations/direction/direction";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
|
||||
/**
|
||||
* Progress — how far along a task is: an upload, an import, an onboarding
|
||||
* checklist. Built on React Aria's ProgressBar, so it is announced with its
|
||||
* label and value, and formats the value for the locale.
|
||||
*
|
||||
* <ProgressBar label="Uploading Q3-report.pdf" value={64} />
|
||||
* <ProgressBar label="Preparing export" isIndeterminate />
|
||||
* <ProgressCircle value={64} aria-label="Upload" />
|
||||
*
|
||||
* The bar is the agent limits card's rounded rail with an accent fill, on
|
||||
* File Upload's track colour. The fill moves by translate rather than width, so it eases
|
||||
* to each new value on the compositor (500ms, the card's ease-out).
|
||||
*
|
||||
* Indeterminate, a band of light sweeps the track, drawn like Spinner and
|
||||
* Composer Loader: a crisp core with a rounded head, a tight glow and a wide
|
||||
* bloom that bleed a few pixels past the rail, the soft layers pulled back
|
||||
* so the sharp head leads. It runs right to left in RTL. The ring's
|
||||
* indeterminate state is Spinner's orbiting light. Reduced motion slows the
|
||||
* sweep rather than stopping it.
|
||||
*/
|
||||
|
||||
export type ProgressSize = "sm" | "md" | "lg";
|
||||
|
||||
const EASE = "ease-[cubic-bezier(0.22,1,0.36,1)]";
|
||||
|
||||
const styles = sortCx({
|
||||
track: {
|
||||
sm: "h-1",
|
||||
md: "h-1.5",
|
||||
lg: "h-2",
|
||||
},
|
||||
ring: {
|
||||
sm: { box: 16, line: 2, spinner: "sm" },
|
||||
md: { box: 24, line: 2.5, spinner: "lg" },
|
||||
lg: { box: 32, line: 3, spinner: "xl" },
|
||||
},
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------- bar */
|
||||
|
||||
export interface ProgressBarProps extends Omit<AriaProgressBarProps, "children" | "className"> {
|
||||
label?: ReactNode;
|
||||
/** Show the formatted value at the end of the label row. Default: shown with a label. */
|
||||
showValueLabel?: boolean;
|
||||
/** Helper text under the bar. */
|
||||
description?: ReactNode;
|
||||
/** Track thickness: 4, 6 (default), or 8px. */
|
||||
size?: ProgressSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The sweeping band of light for an indeterminate bar. */
|
||||
function Sweep() {
|
||||
return (
|
||||
// The clip keeps the light inside the rail's length but lets the glow
|
||||
// and bloom bleed past its thickness.
|
||||
<span aria-hidden className="absolute inset-0 [clip-path:inset(-8px_0_round_9999px)]">
|
||||
<span
|
||||
className={cx(
|
||||
"absolute inset-y-0 left-0 w-2/5",
|
||||
"animate-[bui-progress-sweep_1.6s_cubic-bezier(0.65,0,0.35,1)_infinite]",
|
||||
"rtl:[animation-direction:reverse] motion-reduce:[animation-duration:4s]",
|
||||
)}
|
||||
>
|
||||
<span className="absolute inset-0 rtl:-scale-x-100">
|
||||
<span className="absolute -inset-y-[5px] right-[10px] left-0 rounded-full bg-linear-to-r from-transparent to-current opacity-30 blur-[5px]" />
|
||||
<span className="absolute -inset-y-[1.5px] right-[4px] left-[25%] rounded-full bg-linear-to-r from-transparent to-current opacity-60 blur-[2px]" />
|
||||
<span className="absolute inset-y-0 right-0 left-[35%] rounded-full bg-linear-to-r from-transparent to-current" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProgressTrackProps {
|
||||
/** 0 to 100. */
|
||||
percentage: number;
|
||||
size?: ProgressSize;
|
||||
/** Draw the indeterminate sweep instead of a fill. */
|
||||
isIndeterminate?: boolean;
|
||||
/** Colour of the fill, from the text colour. Default accent. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The rail and its fill, shared by ProgressBar and Meter. */
|
||||
export function ProgressTrack({ percentage, size = "md", isIndeterminate = false, className }: ProgressTrackProps) {
|
||||
const direction = useDirection();
|
||||
return (
|
||||
// The rail is File Upload's ring track colour, which keeps an edge on
|
||||
// every surface in both themes (chart-track matches dark cards).
|
||||
<div className={cx("relative w-full rounded-full bg-border-button-default text-accent-500", styles.track[size], className)}>
|
||||
{isIndeterminate ? (
|
||||
<Sweep />
|
||||
) : (
|
||||
<span className="absolute inset-0 overflow-hidden rounded-full">
|
||||
<span
|
||||
className={cx("absolute inset-0 rounded-full bg-current transition-[translate] duration-500 motion-reduce:transition-none", EASE)}
|
||||
style={{ translate: `${(direction === "rtl" ? 1 : -1) * (100 - Math.min(100, Math.max(0, percentage)))}% 0` }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
label,
|
||||
showValueLabel,
|
||||
description,
|
||||
size = "md",
|
||||
isIndeterminate,
|
||||
className,
|
||||
...props
|
||||
}: ProgressBarProps) {
|
||||
const showValue = showValueLabel ?? (label != null && !isIndeterminate);
|
||||
|
||||
return (
|
||||
<AriaProgressBar {...props} isIndeterminate={isIndeterminate} className={cx("flex w-full flex-col gap-1.5", className)}>
|
||||
{({ percentage = 0, valueText }) => (
|
||||
<>
|
||||
{label != null || showValue ? (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
{label != null ? <Label className="min-w-0 truncate">{label}</Label> : <span />}
|
||||
{showValue ? (
|
||||
<span className="shrink-0 text-body-medium text-text-secondary tabular-nums">{valueText}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<ProgressTrack percentage={percentage} size={size} isIndeterminate={isIndeterminate} />
|
||||
{description ? <span className="text-caption-1-medium text-text-secondary">{description}</span> : null}
|
||||
</>
|
||||
)}
|
||||
</AriaProgressBar>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- ring */
|
||||
|
||||
export interface ProgressCircleProps extends Omit<AriaProgressBarProps, "children" | "className"> {
|
||||
/** 16, 24 (default), or 32px. */
|
||||
size?: ProgressSize;
|
||||
/** Colour of the arc, from the text colour. Default accent. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ring for tight spots: a file tile, a table cell, a checklist header. Name
|
||||
* it with `aria-label`. The arc draws by stroke-dashoffset, a paint-only
|
||||
* change on a few pixels of SVG, eased like the bar.
|
||||
*/
|
||||
export function ProgressCircle({ size = "md", isIndeterminate, className, ...props }: ProgressCircleProps) {
|
||||
const { box, line, spinner } = styles.ring[size];
|
||||
const radius = (box - line) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
return (
|
||||
<AriaProgressBar {...props} isIndeterminate={isIndeterminate} className="inline-flex shrink-0">
|
||||
{({ percentage = 0 }) =>
|
||||
isIndeterminate ? (
|
||||
<SpinnerGlyph size={spinner as "sm" | "lg" | "xl"} className={className} />
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={box}
|
||||
height={box}
|
||||
viewBox={`0 0 ${box} ${box}`}
|
||||
className={cx("-rotate-90 text-accent-500", className)}
|
||||
>
|
||||
<circle cx={box / 2} cy={box / 2} r={radius} fill="none" strokeWidth={line} className="stroke-border-button-default" />
|
||||
<circle
|
||||
cx={box / 2}
|
||||
cy={box / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={line}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * (1 - percentage / 100)}
|
||||
className={cx("transition-[stroke-dashoffset] duration-500 motion-reduce:transition-none", EASE)}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
</AriaProgressBar>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
95
components/base/skeleton/skeleton.tsx
Normal file
95
components/base/skeleton/skeleton.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
|
||||
/**
|
||||
* Skeleton — placeholders in the shape of the content that is on its way.
|
||||
*
|
||||
* <Skeleton shape="circle" className="size-10" />
|
||||
* <SkeletonText lines={3} />
|
||||
* <Skeleton isLoaded={user != null}><Avatar … /></Skeleton>
|
||||
*
|
||||
* Blocks are tinted from the text colour at 8%, so they show on any surface
|
||||
* in both themes (a fixed grey disappears on dark cards). A sheen of light
|
||||
* sweeps each one, drawn in layers like Spinner and Progress: a crisp 2px
|
||||
* line leads, a tight glow and a wide bloom trail it. It mirrors in RTL and
|
||||
* gives way to a gentle opacity pulse under reduced motion. The sheen lives
|
||||
* in globals.css (`.bui-skeleton`).
|
||||
*
|
||||
* With children, a Skeleton sizes itself to them: it lays the real content
|
||||
* out invisibly, then, once `isLoaded`, cross-fades the block away while the
|
||||
* content sharpens in from a 4px blur.
|
||||
*
|
||||
* Skeletons are hidden from assistive technology; mark the region that is
|
||||
* loading with `aria-busy`.
|
||||
*/
|
||||
|
||||
export type SkeletonShape = "rect" | "text" | "circle";
|
||||
|
||||
const styles = sortCx({
|
||||
block: "bui-skeleton block bg-text-primary/8",
|
||||
shape: {
|
||||
rect: "rounded-xl",
|
||||
text: "rounded-md",
|
||||
circle: "rounded-full",
|
||||
},
|
||||
});
|
||||
|
||||
const FADE = "transition-[opacity,filter] duration-300 ease-out motion-reduce:transition-none";
|
||||
|
||||
export interface SkeletonProps {
|
||||
/** `rect` (radius 12, default), `text` (radius 6), or `circle`. */
|
||||
shape?: SkeletonShape;
|
||||
/** With children: show them once true, blurring them in. */
|
||||
isLoaded?: boolean;
|
||||
/** Size and placement, e.g. `h-4 w-40` or `size-10`. */
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** Real content to measure the placeholder from and reveal when loaded. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Skeleton({ shape = "rect", isLoaded = false, className, style, children }: SkeletonProps) {
|
||||
if (children === undefined) {
|
||||
return <span aria-hidden className={cx(styles.block, "relative", styles.shape[shape], className)} style={style} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cx("relative block", className)} style={style}>
|
||||
<span
|
||||
aria-hidden={!isLoaded || undefined}
|
||||
inert={!isLoaded}
|
||||
className={cx("block", FADE, isLoaded ? "opacity-100 blur-[0px]" : "opacity-0 motion-safe:blur-[4px]")}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cx(styles.block, styles.shape[shape], "pointer-events-none absolute inset-0", FADE, isLoaded && "opacity-0")}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SkeletonTextProps {
|
||||
/** Number of lines. Default 3; the last one runs 60% wide. */
|
||||
lines?: number;
|
||||
/** Line height, matched to the text it stands in for. Default `h-3.5` (Body 1). */
|
||||
lineClassName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkeletonText({ lines = 3, lineClassName = "h-3.5", className }: SkeletonTextProps) {
|
||||
return (
|
||||
<span aria-hidden className={cx("flex w-full flex-col gap-2.5", className)}>
|
||||
{Array.from({ length: lines }, (_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
shape="text"
|
||||
className={cx(lineClassName, index === lines - 1 && lines > 1 ? "w-3/5" : "w-full")}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
129
components/base/spinner/spinner.tsx
Normal file
129
components/base/spinner/spinner.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { ProgressBar as AriaProgressBar } from "react-aria-components";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
/**
|
||||
* Spinner — an orbiting light for work of unknown length.
|
||||
*
|
||||
* <Spinner /> // 20px, accent
|
||||
* <Spinner size="sm" className="text-white" /> // inside a primary button
|
||||
*
|
||||
* The light is drawn the way Composer Loader draws its band: the same arc
|
||||
* at three scales, a crisp line with a rounded head, a tight glow and a wide
|
||||
* bloom, each on its own blur. The soft layers carry shorter arcs and turn a
|
||||
* few degrees behind, so the sharp head always leads and the glow gathers
|
||||
* just behind it like the tail of a comet. A faint track completes the
|
||||
* circle. Everything is drawn in `currentColor`, so a text colour sets it.
|
||||
*
|
||||
* Announced as an indeterminate progress bar labelled "Loading" (override
|
||||
* with `aria-label`). Pass `aria-hidden` where the surrounding control
|
||||
* already says it is busy, e.g. a pending button. Reduced motion slows the
|
||||
* orbit rather than stopping it: the motion is the message.
|
||||
*/
|
||||
|
||||
export type SpinnerSize = "xs" | "sm" | "md" | "lg" | "xl";
|
||||
|
||||
/** Box size and line width in px. */
|
||||
const SIZES: Record<SpinnerSize, { box: number; line: number }> = {
|
||||
xs: { box: 12, line: 1.5 },
|
||||
sm: { box: 16, line: 2 },
|
||||
md: { box: 20, line: 2 },
|
||||
lg: { box: 24, line: 2.5 },
|
||||
xl: { box: 32, line: 3 },
|
||||
};
|
||||
|
||||
/** One scale of the light: a conic arc masked to a ring, blurred by its wrapper. */
|
||||
function Arc({
|
||||
box,
|
||||
line,
|
||||
width,
|
||||
blur,
|
||||
opacity,
|
||||
arc,
|
||||
lag,
|
||||
}: {
|
||||
box: number;
|
||||
line: number;
|
||||
/** Ring thickness, px. */
|
||||
width: number;
|
||||
blur: number;
|
||||
opacity: number;
|
||||
/** Arc length in degrees; the head sits at 12 o'clock before the turn. */
|
||||
arc: number;
|
||||
/** Degrees this layer trails the crisp head. */
|
||||
lag: number;
|
||||
}) {
|
||||
const center = box / 2 - line / 2;
|
||||
const inner = center - width / 2;
|
||||
const outer = center + width / 2;
|
||||
// The wrapper is grown to hold the ring plus its blur, so nothing clips.
|
||||
const bleed = Math.max(0, outer + blur * 2 - box / 2);
|
||||
const ring = `radial-gradient(circle, transparent ${inner}px, #000 ${inner + 0.5}px, #000 ${outer - 0.5}px, transparent ${outer}px)`;
|
||||
const style: CSSProperties = {
|
||||
inset: -bleed,
|
||||
filter: blur > 0 ? `blur(${blur}px)` : undefined,
|
||||
opacity,
|
||||
rotate: `${-lag}deg`,
|
||||
};
|
||||
return (
|
||||
<span aria-hidden className="absolute" style={style}>
|
||||
<span
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `conic-gradient(transparent ${360 - arc}deg, currentColor 360deg)`,
|
||||
maskImage: ring,
|
||||
WebkitMaskImage: ring,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SpinnerProps {
|
||||
/** 12, 16, 20 (default), 24, or 32px. */
|
||||
size?: SpinnerSize;
|
||||
/** Draw the faint full circle under the light. Default true. */
|
||||
track?: boolean;
|
||||
/** Accessible name. Default "Loading". */
|
||||
"aria-label"?: string;
|
||||
/** Hide it from assistive technology when a parent already announces the wait. */
|
||||
"aria-hidden"?: boolean;
|
||||
/** Colour comes from the text colour, e.g. `text-white`. Default accent. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SpinnerGlyph({ size = "md", track = true, className }: Omit<SpinnerProps, "aria-label" | "aria-hidden">) {
|
||||
const { box, line } = SIZES[size];
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cx("relative inline-flex shrink-0 text-accent-500", className)}
|
||||
style={{ width: box, height: box }}
|
||||
>
|
||||
{track ? (
|
||||
<span className="absolute inset-0 rounded-full border-current opacity-15" style={{ borderWidth: line }} />
|
||||
) : null}
|
||||
<span className="absolute inset-0 animate-spin [animation-duration:900ms] motion-reduce:[animation-duration:2400ms]">
|
||||
<Arc box={box} line={line} width={line * 4} blur={line * 1.6} opacity={0.32} arc={110} lag={22} />
|
||||
<Arc box={box} line={line} width={line * 2} blur={line * 0.6} opacity={0.6} arc={200} lag={9} />
|
||||
<Arc box={box} line={line} width={line} blur={0} opacity={1} arc={290} lag={0} />
|
||||
{/* The crisp line's rounded head, at the arc's leading end. */}
|
||||
<span
|
||||
className="absolute left-1/2 top-0 rounded-full bg-current"
|
||||
style={{ width: line, height: line, translate: "-50% 0" }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ size = "md", track = true, "aria-label": ariaLabel = "Loading", "aria-hidden": ariaHidden, className }: SpinnerProps) {
|
||||
if (ariaHidden) return <SpinnerGlyph size={size} track={track} className={className} />;
|
||||
return (
|
||||
<AriaProgressBar isIndeterminate aria-label={ariaLabel} className="inline-flex shrink-0">
|
||||
<SpinnerGlyph size={size} track={track} className={className} />
|
||||
</AriaProgressBar>
|
||||
);
|
||||
}
|
||||
172
components/base/switch/switch.tsx
Normal file
172
components/base/switch/switch.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue