feat: add token system and ui contracts

This commit is contained in:
2026-03-19 14:21:13 +08:00
parent 937855362b
commit 3960e0a0e7
14 changed files with 1389 additions and 32 deletions
+33 -2
View File
@@ -1,4 +1,35 @@
export { cn } from "./lib/cn";
export { cva, cx, type VariantProps } from "./lib/cva";
export { motionDurations, motionEasings } from "./lib/motion";
export {
authoringChecklist,
commonSlotNames,
commonStateNames,
createDataAttributes,
createSlot,
cvaConventions,
dataAttr,
withRootProps,
type AsChildProp,
type ButtonLikeElementProps,
type CommonComponentProps,
type ControllableStateProps,
type DataAttributes,
type DisableableProps,
type FieldStateProps,
type InputLikeElementProps,
type InvalidatableProps,
type LoadingProps,
type PressableStateProps,
type ReadonlyProps,
type RequiredProps,
type SlotAttributes
} from "./lib/contracts";
export {
getMotionRecipeClassNames,
motionDistances,
motionDurations,
motionEasings,
motionRecipes,
motionScales,
type MotionRecipeName
} from "./lib/motion";
+185
View File
@@ -0,0 +1,185 @@
import type { ComponentPropsWithoutRef } from "react";
export type CommonComponentProps = {
className?: string;
};
export type AsChildProp = {
asChild?: boolean;
};
export type DisableableProps = {
disabled?: boolean;
};
export type InvalidatableProps = {
invalid?: boolean;
};
export type LoadingProps = {
loading?: boolean;
};
export type ReadonlyProps = {
readOnly?: boolean;
};
export type RequiredProps = {
required?: boolean;
};
export type PressableStateProps = CommonComponentProps &
DisableableProps &
LoadingProps &
AsChildProp;
export type FieldStateProps = CommonComponentProps &
DisableableProps &
InvalidatableProps &
ReadonlyProps &
RequiredProps;
export type ControllableStateProps<T> = {
defaultValue?: T;
onValueChange?: (value: T) => void;
value?: T;
};
export type DataAttributes = Partial<Record<`data-${string}`, string | undefined>>;
export type SlotAttributes<Name extends string> = {
"data-slot": Name;
};
export const commonSlotNames = [
{
slot: "root",
guidance: "The outermost element rendered by the component."
},
{
slot: "label",
guidance: "Primary visible label or title within the component."
},
{
slot: "description",
guidance: "Secondary supporting copy connected to the component."
},
{
slot: "control",
guidance: "Focusable or interactive control surface."
},
{
slot: "input",
guidance: "Typed value entry element such as input or textarea."
},
{
slot: "trigger",
guidance: "Element that opens, closes, or toggles related content."
},
{
slot: "content",
guidance: "Popover, drawer, menu, dialog, or expandable content region."
},
{
slot: "icon",
guidance: "Decorative or stateful icon container."
}
] as const;
export const commonStateNames = [
{
state: "state",
guidance: "Use for finite machine-like values such as open, closed, active, or inactive."
},
{
state: "disabled",
guidance: "Set when interaction is blocked."
},
{
state: "invalid",
guidance: "Set when validation fails or the field is in an error state."
},
{
state: "loading",
guidance: "Set when the component is waiting on async work."
},
{
state: "readonly",
guidance: "Set when the value can be viewed but not edited."
},
{
state: "required",
guidance: "Set when the field requires a value."
},
{
state: "orientation",
guidance: "Use for horizontal or vertical layout state when styling depends on it."
}
] as const;
export const authoringChecklist = [
"Expose `className` on every styled public component.",
"Forward `ref` on every focusable or measurable public component.",
"Use `asChild` only for components whose root element is meant to be polymorphic.",
"Represent boolean UI states with empty-string `data-*` attributes.",
"Represent finite machine states with `data-state=\"...\"` and keep the values stable.",
"Name internal stylable parts with `data-slot` so variants and docs can target them consistently.",
"Prefer controlled and uncontrolled APIs together when the component manages user state.",
"Consume tokens and motion recipes instead of raw visual values."
] as const;
export const cvaConventions = [
"Put shared layout and focus primitives in the CVA base string, not in individual variants.",
"Reserve `variant` for semantic appearance changes and `size` only when spacing or density genuinely changes.",
"Keep default variants explicit so stories and tests do not depend on implicit visual fallbacks.",
"Prefer a small stable variant surface over one-off booleans that fragment the API."
] as const;
export function dataAttr(active?: boolean) {
return active ? "" : undefined;
}
export function createDataAttributes(
states: Record<string, boolean | number | string | null | undefined>
): DataAttributes {
const attributes: DataAttributes = {};
for (const [name, value] of Object.entries(states)) {
const key = `data-${name}` as const;
if (typeof value === "boolean") {
attributes[key] = dataAttr(value);
continue;
}
if (value !== null && value !== undefined) {
attributes[key] = String(value);
}
}
return attributes;
}
export function createSlot<Name extends string>(name: Name): SlotAttributes<Name> {
return { "data-slot": name };
}
export function withRootProps<T extends object>(
props: T,
options: {
slot?: string;
states?: Record<string, boolean | number | string | null | undefined>;
} = {}
) {
return {
...props,
...(options.slot ? createSlot(options.slot) : {}),
...(options.states ? createDataAttributes(options.states) : {})
};
}
export type ButtonLikeElementProps = ComponentPropsWithoutRef<"button"> &
PressableStateProps;
export type InputLikeElementProps = ComponentPropsWithoutRef<"input"> &
FieldStateProps;
+35 -2
View File
@@ -1,11 +1,44 @@
export const motionDurations = {
instant: "var(--dur-instant)",
fast: "var(--dur-fast)",
base: "var(--dur-base)",
slow: "var(--dur-slow)"
slow: "var(--dur-slow)",
deliberate: "var(--dur-deliberate)"
} as const;
export const motionEasings = {
standard: "var(--ease-standard)",
emphasized: "var(--ease-emphasized)"
emphasized: "var(--ease-emphasized)",
exit: "var(--ease-exit)"
} as const;
export const motionDistances = {
xs: "var(--distance-xs)",
sm: "var(--distance-sm)",
md: "var(--distance-md)",
lg: "var(--distance-lg)"
} as const;
export const motionScales = {
press: "var(--scale-press)",
hover: "var(--scale-hover)",
pop: "var(--scale-pop)"
} as const;
export const motionRecipes = {
transition: "motion-transition",
pressable: "motion-pressable",
enterFade: "motion-enter-fade",
enterRise: "motion-enter-rise",
overlayEnter: "motion-overlay-enter",
overlayExit: "motion-overlay-exit",
exitFade: "motion-exit-fade",
exitDrop: "motion-exit-drop",
ring: "motion-ring"
} as const;
export type MotionRecipeName = keyof typeof motionRecipes;
export function getMotionRecipeClassNames(...recipes: MotionRecipeName[]) {
return recipes.map((recipe) => motionRecipes[recipe]).join(" ");
}