This commit is contained in:
2026-03-22 17:03:54 +08:00
parent 8e76dd7a7b
commit 5c6d8c6b92
49 changed files with 203 additions and 1442 deletions

View File

@@ -40,7 +40,7 @@
"vite-plugin-dts": "^4.5.4"
},
"dependencies": {
"@floating-ui/react": "^0.27.18",
"@base-ui/react": "^1.3.0",
"@tailwindcss/vite": "^4.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",

View File

@@ -1,8 +1,7 @@
import React from "react";
import { useThemeContext } from "../ThemeProvider/useThemeContext";
import { useThemeContext } from "./ThemeProvider/useThemeContext";
import { cn } from "tailwind-variants";
import type { CommonProps } from "@/common/CommonProps";
import { boxRoot } from "./Box.recipe";
// 别名<约束>=值
// 千万不要 C = As extend React.ElementType这样子连等号会切断推导
@@ -29,7 +28,7 @@ const Box = <C extends React.ElementType = "div">(
throw new Error("Box must be used within a ThemeProvider");
}
const boxRootClass = cn(themeClass, boxRoot(), className);
const boxRootClass = cn(themeClass, className);
return (
<Component ref={ref} className={boxRootClass} {...(rest as any)}>

View File

@@ -0,0 +1,45 @@
"use client";
import * as BUI from "@base-ui/react/button";
import { cn } from "tailwind-variants";
import type { CommonProps } from "@/common/CommonProps";
import { itemRootRecipe } from "@/styles/recipe/ItemRoot.recipe";
import { variantRecipe } from "@/styles/recipe/variant.recipe";
import type { ReactNode } from "react";
import { inlineRootRecipe } from "@/styles/recipe/IinlineRoot.recipe";
type ButtonProps = CommonProps & {
size?: "md" | "lg" | "xl";
variant?: "filled" | "outline" | "subtle";
loading?: boolean;
icon?: ReactNode;
iconOnly?: boolean;
hideIcon?: boolean;
};
export const Button = (props: ButtonProps) => {
const {
className,
children,
size = "md",
variant = "filled",
loading,
disabled,
icon,
iconOnly,
hideIcon,
} = props;
const buttonCls = cn(
itemRootRecipe({ size }),
variantRecipe({ variant, disabled: loading || disabled }),
className,
);
const iconCls = cn(inlineRootRecipe({ size, iconOnly }));
return (
<BUI.Button className={buttonCls} disabled={loading || disabled}>
{children}
</BUI.Button>
);
};

View File

@@ -23,14 +23,14 @@ export * from './assets/svg/VolumeLowSvg.tsx';
export * from './assets/svg/VolumeMediumSvg.tsx';
export * from './assets/svg/VolumeMuteSvg.tsx';
export * from './common/CommonProps.ts';
export * from './lv1-fundamental/Box/Box.tsx';
export * from './common/Box.tsx';
export * from './lv1-fundamental/Slot/Slot.tsx';
export * from './lv1-fundamental/ThemeProvider/ThemeContext.ts';
export * from './lv1-fundamental/ThemeProvider/ThemeProvider.tsx';
export * from './lv1-fundamental/ThemeProvider/useThemeContext.ts';
export * from './common/ThemeProvider/ThemeContext.ts';
export * from './common/ThemeProvider/ThemeProvider.tsx';
export * from './common/ThemeProvider/useThemeContext.ts';
export * from './lv2-sized/Root/RootInline.recipe';
export * from './lv2-sized/Root/RootInline.ts';
export * from './lv2-sized/ItemRoot/ItemRoot.recipe.ts';
export * from './styles/recipe/ItemRoot.recipe.ts';
export * from './lv2-sized/ItemRoot/ItemRoot.tsx';
export * from './lv2-sized/Section/Section.recipe.ts';
export * from './lv2-sized/Section/Section.tsx';

View File

@@ -1,5 +0,0 @@
import { tv } from "tailwind-variants";
export const boxRoot = tv({
base: "flex justify-center items-center",
});

View File

@@ -1,75 +0,0 @@
import type { CommonProps } from "@/common/CommonProps";
import { cn } from "tailwind-variants";
import Box, { type PolymorphicProps } from "@/lv1-fundamental/Box/Box";
import { inlineRootRecipe } from "./IinlineRoot.recipe";
interface ItemInlineRootProps<C extends React.ElementType> extends CommonProps {
as?: C;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
shape?: "square" | "rounded" | "circle";
variant?: "filled" | "outline" | "subtle";
brand?: "success" | "danger" | "info" | "warning" | "emphasis";
iconOnly?: boolean;
}
const ItemInlineRoot = <C extends React.ElementType = "span">(
props: PolymorphicProps<C, ItemInlineRootProps<C>>,
ref?: React.ComponentPropsWithRef<C>["ref"],
) => {
const {
as = "span",
size = "sm",
shape = "square",
brand,
iconOnly = false,
variant,
onClick,
disabled = false,
className,
children,
...rest
} = props;
const handleClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
if (disabled) {
e.preventDefault();
return;
}
onClick?.(e);
};
const itemInlineRootClass = cn(
inlineRootRecipe({
variant,
size,
shape,
iconOnly,
disabled,
brand,
}),
className,
);
return (
<Box
as={as}
className={itemInlineRootClass}
onClick={handleClick}
disabled={disabled}
ref={ref}
{...(rest as any)}
>
{children}
</Box>
);
};
ItemInlineRoot.displayName = "ItemInlineRoot";
export type ItemInlineRootComponent = <C extends React.ElementType = "span">(
props: PolymorphicProps<C, ItemInlineRootProps<C>> & {
ref?: React.ComponentPropsWithRef<C>["ref"];
},
) => React.ReactElement | null;
export default ItemInlineRoot as ItemInlineRootComponent;

View File

@@ -1,81 +0,0 @@
import type { CommonProps } from "@/common/CommonProps";
import { itemRootRecipe } from "./ItemRoot.recipe";
import { cn } from "tailwind-variants";
import Box, { type PolymorphicProps } from "@/lv1-fundamental/Box/Box";
interface ItemRootProps<C extends React.ElementType> extends CommonProps {
as?: C;
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
shape?: "square" | "rounded" | "circle";
variant?: "filled" | "outline" | "subtle";
brand?: "success" | "danger" | "info" | "warning" | "emphasis";
hasShadow?: boolean;
iconOnly?: boolean;
}
const ItemRoot = <C extends React.ElementType = "div">(
props: PolymorphicProps<C, ItemRootProps<C>>,
ref?: React.ComponentPropsWithRef<C>["ref"],
) => {
const {
as = "div",
size = "sm",
shape = "rounded",
brand: propBrand,
iconOnly = false,
variant,
hasShadow = false,
onClick,
disabled = false,
className,
children,
...rest
} = props;
const brand =
variant == "filled" && propBrand == undefined ? "info" : propBrand;
const handleClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
if (disabled) {
e.preventDefault();
return;
}
onClick?.(e);
};
const itemRootClass = cn(
itemRootRecipe({
variant,
size,
shape,
iconOnly,
disabled,
hasShadow,
brand,
}),
className,
);
return (
<Box
as={as}
className={itemRootClass}
onClick={handleClick}
disabled={disabled}
ref={ref}
{...(rest as any)}
>
{children}
</Box>
);
};
ItemRoot.displayName = "ItemRoot";
export type ItemRootComponent = <C extends React.ElementType = "div">(
props: PolymorphicProps<C, ItemRootProps<C>> & {
ref?: React.ComponentPropsWithRef<C>["ref"];
},
) => React.ReactElement | null;
export default ItemRoot as ItemRootComponent;

View File

@@ -1,12 +0,0 @@
import { tv } from "tailwind-variants";
export const sectionRoot = tv({
base: "",
variants: {
align: {
start: "justify-start",
center: "justify-center",
end: "justify-end",
},
},
});

View File

@@ -1,23 +0,0 @@
import type { CommonProps } from "@/common/CommonProps";
import { forwardRef } from "react";
import { sectionRoot } from "./Section.recipe";
import Box from "@/lv1-fundamental/Box/Box";
import { cn } from "tailwind-variants";
type SectionProps = CommonProps & {
align?: "start" | "center" | "end";
};
export const Section = forwardRef<HTMLDivElement, SectionProps>(
(props, ref) => {
const { className, align, children, ...rest } = props;
const sectionRootClass = cn(sectionRoot({ align }), className);
return (
<Box className={sectionRootClass} ref={ref} {...rest}>
{children}
</Box>
);
},
);

View File

@@ -1,19 +0,0 @@
import { tv } from "tailwind-variants";
export const iconRoot = tv({
base: "dg-icon",
});
export const iconSvg = tv({
base: "dg-icon-svg",
variants: {
size: {
xs: "dg-icon-svg_size--xs",
sm: "dg-icon-svg_size--sm",
md: "dg-icon-svg_size--md",
lg: "dg-icon-svg_size--lg",
xl: "dg-icon-svg_size--xl",
"2xl": "dg-icon-svg_size--2xl",
},
},
});

View File

@@ -1,25 +0,0 @@
import type { CommonProps } from "@/common/CommonProps";
import { forwardRef } from "react";
import { iconRoot, iconSvg } from "./Icon.recipe.ts";
import { Slot } from "@/lv1-fundamental/Slot/Slot";
import { cn } from "tailwind-variants";
import InlineRoot from "@/lv2-sized/InlineRoot/InlineRoot.tsx";
type IconProps = CommonProps & {
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
};
export const Icon = forwardRef<HTMLSpanElement, IconProps>((props, ref) => {
const { size, children, className, ...rest } = props;
const iconRootClass = cn(iconRoot(), className);
const iconSvgClass = iconSvg({ size });
return (
<InlineRoot size={size} className={iconRootClass} ref={ref} {...rest}>
<Slot className={iconSvgClass}>{children}</Slot>
</InlineRoot>
);
});
Icon.displayName = "Icon";

View File

@@ -1,5 +0,0 @@
import { tv } from "tailwind-variants";
export const indicatorInput = tv({ base: "pointer-none" });
export const indicatorBoxSvg = tv({ base: "absolute z-10" });
export const indicatorCheckSvg = tv({ base: "absolute z-20" });

View File

@@ -1,92 +0,0 @@
import { forwardRef, type ReactNode } from "react";
import {
// indicatorBoxSvg,
// indicatorCheckSvg,
indicatorInput,
} from "./Indicator.recipe";
import type { CommonProps } from "@/common/CommonProps";
import InlineRoot from "@/lv2-sized/InlineRoot/InlineRoot";
// import { CheckIndicatorOutlineSvg } from "@/assets/svg/CheckIndicatorOutlineSvg";
// import { RadioIndicatorOutlineSvg } from "@/assets/svg/RadioIndicatorOutlineSvg";
// import { RadioIndicatorSvg } from "@/assets/svg/RadioIndicatorSvg";
// import { CheckIndicatorSvg } from "@/assets/svg/CheckIndicatorSvg";
type IndicatorProps = CommonProps & {
size?: "xs" | "sm";
type?: "checkbox" | "radio";
boxSvg?: ReactNode;
checkSvg?: ReactNode;
checked?: boolean;
id?: string;
};
export const Indicator = forwardRef<HTMLInputElement, IndicatorProps>(
(props, ref) => {
const {
size = "sm",
type = "checkbox",
// boxSvg,
// checkSvg,
checked,
id,
disabled,
} = props;
// const currentBoxSvg = boxSvg ? (
// boxSvg
// ) : type == "checkbox" ? (
// <CheckIndicatorOutlineSvg />
// ) : (
// <RadioIndicatorOutlineSvg />
// );
// const currentCheckSvg = checkSvg ? (
// checkSvg
// ) : type == "checkbox" ? (
// <CheckIndicatorSvg />
// ) : (
// <RadioIndicatorSvg />
// );
const indicatorInputClass = indicatorInput();
// const indicatorBoxSvgClass = indicatorBoxSvg();
// const indicatorCheckSvgClass = indicatorCheckSvg();
return (
<InlineRoot size={size} iconOnly={true}>
<InlineRoot
as="input"
id={id}
type={type}
size={size}
ref={ref}
iconOnly={true}
checked={checked} // 必须显式绑定,但还需要阻止默认行为
// 此处仅用于阻止默认行为,不要处理 click 逻辑
onClick={(e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault(); // 阻止默认行为,防止闪烁
return false;
}}
// 此处仅用于阻止默认行为,不要处理 keydown 逻辑
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault(); // 阻止默认行为,防止闪烁
return false;
}}
disabled={disabled} // 交互类 disabled 需要手动传入,而不是依靠父元素灰色滤镜
className={indicatorInputClass}
/>
{/* <Icon className={indicatorBoxSvgClass} size={size}>
{currentBoxSvg}
</Icon>
{checked && (
<Icon className={indicatorCheckSvgClass} size={size}>
{currentCheckSvg}
</Icon>
)} */}
</InlineRoot>
);
},
);
Indicator.displayName = "Indicator";

View File

@@ -1,30 +0,0 @@
@use "../../styles/index.scss" as *;
.dg-label {
@include add-specificity(3) {
}
}
.dg-label_align--start {
@include add-specificity(3) {
@include justify-start;
}
}
.dg-label_align--center {
@include add-specificity(3) {
@include justify-center;
}
}
.dg-label_align--end {
@include add-specificity(3) {
@include justify-end;
}
}
.dg-label_is-subtext--true {
@include add-specificity(3) {
@include color-subtext;
}
}
.dg-label_is-subtext--false {
@include add-specificity(3) {
}
}

View File

@@ -1,16 +0,0 @@
import { tv } from "tailwind-variants";
export const labelRoot = tv({
base: "dg-label",
variants: {
align: {
start: "dg-label_align--start",
center: "dg-label_align--center",
end: "dg-label_align--end",
},
isSubText: {
true: "dg-label_is-subtext--true",
false: "dg-label_is-subtext--false",
},
},
});

View File

@@ -1,39 +0,0 @@
import type { CommonProps } from "@/common/CommonProps";
import { forwardRef } from "react";
import { cn } from "tailwind-variants";
import { labelRoot } from "./Label.style";
import { RootInline } from "@/lv2-sized/Root/RootInline";
type LabelProps = CommonProps & {
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
align?: "start" | "center" | "end";
isSubText?: boolean;
for?: string;
};
export const Label = forwardRef<HTMLLabelElement, LabelProps>((props, ref) => {
const {
size,
isSubText = false,
align,
children,
className,
...rest
} = props;
const labelRootClass = cn(labelRoot({ align, isSubText }), className);
return (
<RootInline
as="label"
size={size}
className={labelRootClass}
ref={ref}
{...rest}
>
{children}
</RootInline>
);
});
Label.displayName = "Label";

View File

@@ -1,7 +0,0 @@
@use "../../styles/index.scss" as *;
.dg-tooltip-dropdown {
@include add-specificity(3) {
border: 1px solid;
}
}

View File

@@ -1,70 +0,0 @@
import { Slot } from "@/lv1-fundamental/Slot/Slot";
import Root from "@/lv2-sized/ItemRoot/ItemRoot";
import type { CommonProps } from "@/common/CommonProps";
import {
autoUpdate,
flip,
FloatingPortal,
offset,
shift,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useRole,
type Placement,
} from "@floating-ui/react";
import { useState, type ReactNode } from "react";
type TooltipProps = CommonProps & {
title?: ReactNode;
placement?: Placement;
};
export const Tooltip = (props: TooltipProps) => {
const { children, placement = "top", title } = props;
const [open, setOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open: open,
onOpenChange: setOpen,
placement: placement,
middleware: [offset(4), flip(), shift()],
whileElementsMounted: autoUpdate,
});
const hover = useHover(context, {
delay: { open: 0, close: 0 },
});
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: "tooltip" });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
return (
<>
<Slot ref={refs.setReference} {...getReferenceProps()}>
{children}
</Slot>
{open && (
<FloatingPortal>
<Root
size="fit"
ref={refs.setFloating}
style={floatingStyles}
hasShadow={true}
{...getFloatingProps()}
>
{title}
</Root>
</FloatingPortal>
)}
</>
);
};

View File

@@ -1,31 +0,0 @@
@use "../../styles/index.scss" as *;
.dg-button {
@include add-specificity(4) {
padding-block: 0;
}
}
.dg-button-content {
@include add-specificity(4) {
}
}
.dg-button_isloading--true {
@include add-specificity(4) {
@include loading-true;
}
}
.dg-button_isloading--false {
@include add-specificity(4) {
@include loading-false;
}
}
.dg-button-loading-icon {
@include add-specificity(4) {
@include absolute;
}
}
.dg-button-icon {
@include add-specificity(4) {
}
}

View File

@@ -1,93 +0,0 @@
import Box from "@/lv1-fundamental/Box/Box";
import Root from "@/lv2-sized/ItemRoot/ItemRoot";
import { Icon } from "@/lv3-partial/Icon/Icon";
import { Label } from "@/lv3-partial/Label/Label";
import type { CommonProps } from "@/common/CommonProps";
import { forwardRef } from "react";
import {
buttonContent,
buttonIcon,
buttonLoadingIcon,
buttonRoot,
} from "./Button.recipe";
import { cn } from "tailwind-variants";
import { SpinnerSvg } from "@/assets/svg/SpinnerSvg";
type ButtonProps = CommonProps & {
size?: "xs" | "sm" | "md" | "lg";
shape?: "circle" | "rounded" | "square";
variant?: "filled" | "outline" | "subtle";
isLoading?: boolean;
loadingIcon?: React.ReactNode;
iconSvg?: React.ReactNode;
iconOnly?: boolean;
hideIcon?: boolean; // not a state just a attribute
onClick?: () => void;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
size = "md",
shape = "rounded",
variant = "filled",
iconOnly = false,
iconSvg,
hideIcon = false,
isLoading,
loadingIcon,
children,
className,
disabled,
...rest
} = props;
const buttonRootClass = cn(buttonRoot({ iconOnly }), className);
const buttonIconClass = buttonIcon();
const buttonContentClass =
(isLoading != undefined && buttonContent({ isLoading })) || "";
const buttonLoadingIconClass = buttonLoadingIcon({
isLoading,
});
return (
<Root
ref={ref}
as="button"
size={size}
variant={variant}
shape={shape}
iconOnly={iconOnly}
className={buttonRootClass}
{...rest}
>
<Box className={buttonContentClass}>
{iconOnly && (
// if iconOnly
<Icon size={size} className={buttonIconClass}>
{iconSvg && !hideIcon ? iconSvg : null}
</Icon>
)}
{!iconOnly &&
// if not iconOnly
iconSvg &&
!hideIcon ? (
<Icon size={size}>{iconSvg}</Icon>
) : null}
{!iconOnly ? <Label size={size}>{children}</Label> : null}
</Box>
{isLoading != undefined && (
<Icon size={size} className={buttonLoadingIconClass}>
<SpinnerSvg />
</Icon>
)}
</Root>
);
},
);
Button.displayName = "Button";

View File

@@ -1,8 +0,0 @@
import { tv } from "tailwind-variants";
export const checkboxRoot = tv({
base: "dg-checkbox",
});
export const checkboxLabel = tv({
base: "dg-checkbox-label",
});

View File

@@ -1,13 +0,0 @@
@use "../../styles/index.scss" as *;
.dg-checkbox {
@include add-specificity(4) {
cursor: pointer;
padding-block: 0;
}
}
.dg-checkbox-label {
@include add-specificity(4) {
cursor: pointer;
}
}

View File

@@ -1,103 +0,0 @@
import { RootInline } from "@/lv2-sized/Root/RootInline";
import { Icon } from "@/lv3-partial/Icon/Icon";
import { Label } from "@/lv3-partial/Label/Label";
import type { CommonProps } from "@/common/CommonProps";
import { forwardRef, useState, type ReactNode } from "react";
import { cn } from "tailwind-variants";
import { checkboxLabel, checkboxRoot } from "./Checkbox.recipe";
import Root from "@/lv2-sized/ItemRoot/ItemRoot";
import { Indicator } from "@/lv3-partial/Indicator/Indicator";
type CheckboxProps = CommonProps & {
id?: string;
checked?: boolean;
defaultChecked?: boolean;
onChange?: (v: boolean) => void;
size?: "xs" | "sm";
isPlaceholder?: boolean;
indicatorBoxSvg?: ReactNode;
indicatorCheckSvg?: ReactNode;
iconSvg?: ReactNode;
};
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(props, ref) => {
const {
id,
checked: controllerChecked,
defaultChecked = false,
onChange,
size = "sm",
isPlaceholder = false,
indicatorBoxSvg,
indicatorCheckSvg,
iconSvg,
className,
children,
disabled = false,
...rest
} = props;
const isControlled = controllerChecked !== undefined;
const [innerChecked, setInnerChecked] = useState(defaultChecked ?? false);
const currentChecked = isControlled ? controllerChecked : innerChecked;
const handleClick = (_e: React.MouseEvent<HTMLInputElement>) => {
// 若受控,点击不变
if (isControlled) {
return false;
}
// 若不受控,点击变更
const next = !currentChecked;
setInnerChecked(next);
onChange?.(next);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// 若受控,点击不变
if (isControlled) {
return false;
}
// 若不受控,点击变更
const next = !currentChecked;
if (e.key === " " || e.key === "Enter") {
setInnerChecked(next);
}
onChange?.(next);
};
const checkboxRootClass = cn(checkboxRoot(), className);
const checkboxLabelClass = checkboxLabel();
return (
<Root
size={size}
className={checkboxRootClass}
onClick={handleClick}
onKeyDown={handleKeyDown}
disabled={disabled} // 不同于 input 的 disabled此处仅提供灰色滤镜
{...rest}
>
{isPlaceholder ? (
<RootInline size={size} iconOnly={true} />
) : (
<Indicator
size={size}
disabled={disabled}
type="checkbox"
checked={currentChecked}
ref={ref}
/>
)}
{iconSvg ? <Icon size={size}>{iconSvg}</Icon> : null}
<Label size={size} for={id} className={checkboxLabelClass}>
{children}
</Label>
</Root>
);
},
);
Checkbox.displayName = "Checkbox";

View File

@@ -1,9 +0,0 @@
import { tv } from "tailwind-variants";
export const radioRoot = tv({
base: "dg-radio",
});
export const radioLabel = tv({
base: "dg-radio-label",
});

View File

@@ -1,13 +0,0 @@
@use "../../styles/index.scss" as *;
.dg-radio {
@include add-specificity(4) {
cursor: pointer;
padding-block: 0;
}
}
.dg-radio-label {
@include add-specificity(4) {
cursor: pointer;
}
}

View File

@@ -1,114 +0,0 @@
import { RootInline } from "@/lv2-sized/Root/RootInline";
import { Icon } from "@/lv3-partial/Icon/Icon";
import { Label } from "@/lv3-partial/Label/Label";
import type { CommonProps } from "@/common/CommonProps";
import { forwardRef, useContext, useState, type ReactNode } from "react";
import { cn } from "tailwind-variants";
import { radioLabel, radioRoot } from "./Radio.recipe";
import { RadioGroupContext } from "./RadioGroupContext";
import { Indicator } from "@/lv3-partial/Indicator/Indicator";
import Root from "@/lv2-sized/ItemRoot/ItemRoot";
type RadioProps = CommonProps & {
id?: string;
name?: string;
value: string;
checked?: boolean;
defaultChecked?: boolean;
onChange?: (name: string, value: boolean) => void;
size?: "xs" | "sm";
isPlaceholder?: boolean;
indicatorBoxSvg?: ReactNode;
indicatorCheckSvg?: ReactNode;
iconSvg?: ReactNode;
};
export const Radio = forwardRef<HTMLInputElement, RadioProps>((props, ref) => {
const {
checked: controllerChecked,
defaultChecked = false,
onChange,
size = "sm",
isPlaceholder = false,
indicatorBoxSvg,
indicatorCheckSvg,
iconSvg,
className,
children,
disabled = false,
id,
name,
value,
...rest
} = props;
const ctx = useContext(RadioGroupContext);
if (!ctx) {
throw new Error("Radio must be used within a RadioGroup");
}
const isControlled = controllerChecked !== undefined;
const [innerChecked, setInnerChecked] = useState(defaultChecked ?? false);
const currentChecked = isControlled ? controllerChecked : innerChecked;
const currentSize = ctx.size ?? size;
const currentName = ctx.name ?? name;
const handleClick = (_e: React.MouseEvent<HTMLInputElement>) => {
// 若受控,点击不变
if (isControlled) {
return false;
}
// 若不受控,点击变更
const next = !currentChecked;
setInnerChecked(next);
onChange?.(currentName, next);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// 若受控,点击不变
if (isControlled) {
return false;
}
// 若不受控,点击变更
const next = !currentChecked;
if (e.key === " " || e.key === "Enter") {
setInnerChecked(next);
}
onChange?.(currentName, next);
};
const radioRootClass = cn(radioRoot(), className);
const radioLabelClass = radioLabel();
return (
<Root
size={currentSize}
className={radioRootClass}
onClick={handleClick}
onKeyDown={handleKeyDown}
disabled={disabled} // 不同于 input 的 disabled此处仅提供灰色滤镜
{...rest}
>
{isPlaceholder ? (
<RootInline size={currentSize} iconOnly={true} />
) : (
<Indicator
size={currentSize}
disabled={disabled}
type="radio"
checked={currentChecked}
ref={ref}
/>
)}
{iconSvg ? <Icon size={currentSize}>{iconSvg}</Icon> : null}
<Label size={currentSize} for={id} className={radioLabelClass}>
{children}
</Label>
</Root>
);
});
Radio.displayName = "Radio";

View File

@@ -1,15 +0,0 @@
import { tv } from "tailwind-variants";
export const radioGroupRoot = tv({
base: "dg-radio-group",
variants: {
direction: {
horizontal: "dg-radio-group_direction--horizontal",
vertical: "dg-radio-group_direction--vertical",
},
},
});
export const radioGroupList = tv({
base: "dg-radio-group-list",
});

View File

@@ -1,24 +0,0 @@
@use "../../styles/index.scss" as *;
.dg-radio-group {
@include add-specificity(4) {
@include flex-col;
}
}
.dg-radio-group_direction--horizontal {
@include add-specificity(4) {
@include flex-row;
}
}
.dg-radio-group_direction--vertical {
@include add-specificity(4) {
@include flex-col;
}
}
.dg-radio-group-list {
@include add-specificity(4) {
@include flex-col;
@include justify-start;
@include items-start;
}
}

View File

@@ -1,59 +0,0 @@
import type { CommonProps } from "@/common/CommonProps";
import { RadioGroupContext } from "./RadioGroupContext";
import { useState } from "react";
import { Label } from "@/lv3-partial/Label/Label";
import Root from "@/lv2-sized/ItemRoot/ItemRoot";
import { radioGroupList, radioGroupRoot } from "./RadioGroup.recipe";
import Box from "@/lv1-fundamental/Box/Box";
type RadioGroupProps = CommonProps & {
name: string;
label?: string;
defaultValue?: string;
onChange?: (value: string) => void;
size?: "xs" | "sm";
direction?: "vertical" | "horizontal";
};
export const RadioGroup = (props: RadioGroupProps) => {
const {
name,
defaultValue,
onChange,
label,
size = "sm",
direction = "horizontal",
className,
children,
...rest
} = props;
const [value, setValue] = useState(defaultValue ?? "");
const handleChange = (v: string) => {
setValue(v);
onChange?.(v);
};
const radioGroupRootClass = radioGroupRoot({ direction });
const radioGroupListClass = radioGroupList();
return (
<RadioGroupContext.Provider
value={{ value: value, onChange: handleChange, name, size }}
>
<Root className={radioGroupRootClass} {...rest}>
<Box className={radioGroupListClass}>
{label && (
<Root size={size}>
<Label size={size}>
{label}
</Label>
</Root>
)}
{children}
</Box>
</Root>
</RadioGroupContext.Provider>
);
};

View File

@@ -1,12 +0,0 @@
import { createContext } from "react";
export type RadioGroupContextType = {
name: string;
onChange: (v: string) => void;
value: string;
size: "xs" | "sm";
};
export const RadioGroupContext = createContext<RadioGroupContextType | null>(
null,
);

View File

@@ -16,11 +16,6 @@ export const itemRootRecipe = tv({
rounded: "",
circle: "rounded-full",
},
variant: {
filled: "variant-filled",
outline: "variant-outline",
subtle: "variant-subtle",
},
brand: {
success: "brand-success",
danger: "brand-danger",
@@ -103,21 +98,5 @@ export const itemRootRecipe = tv({
size: "2xl",
class: "w-2xl",
},
// --------------------------------------------------
{
disabled: true,
variant: "filled",
class: "variant-filled-disabled",
},
{
disabled: true,
variant: "outline",
class: "variant-outline-disabled",
},
{
disabled: true,
variant: "subtle",
class: "variant-subtle-disabled",
},
],
});

View File

@@ -0,0 +1,32 @@
import { tv } from "tailwind-variants";
export const variantRecipe = tv({
variants: {
variant: {
filled: "variant-filled",
outline: "variant-outline",
subtle: "variant-subtle",
},
disabled: {
true: "",
false: "",
},
},
compoundVariants: [
{
disabled: true,
variant: "filled",
class: "variant-filled-disabled",
},
{
disabled: true,
variant: "outline",
class: "variant-outline-disabled",
},
{
disabled: true,
variant: "subtle",
class: "variant-subtle-disabled",
},
],
});

View File

@@ -1,11 +1,11 @@
@theme {
--color-transparent: transparent;
--danger-fg: var(--base-fg);
--danger-bg-low-hover: var(--color-red-100);
--danger-bg-low-active: var(--color-red-200);
--danger-bg: var(--color-red-600);
--danger-bg-high-hover: var(--color-red-700);
--danger-bg-high-active: var(--color-red-800);
--danger-border-color: var(--color-transparent);
--success-fg: var(--base-fg);
--success-bg-low-hover: var(--color-green-100);
--success-bg-low-active: var(--color-green-200);
@@ -18,19 +18,16 @@
--info-bg: var(--color-sky-600);
--info-bg-high-hover: var(--color-sky-700);
--info-bg-high-active: var(--color-sky-800);
--info-border-color: var(--color-transparent);
--warning-fg: var(--base-fg);
--warning-bg-low-hover: var(--color-amber-100);
--warning-bg-low-active: var(--color-amber-200);
--warning-bg: var(--color-amber-600);
--warning-bg-high-hover: var(--color-amber-700);
--warning-bg-high-active: var(--color-amber-800);
--warning-border-color: var(--color-transparent);
--emphasis-fg: var(--base-fg);
--emphasis-bg-low-hover: var(--color-gray-100);
--emphasis-bg-low-active: var(--color-gray-200);
--emphasis-bg: var(--color-gray-600);
--emphasis-bgr-high-hover: var(--color-gray-700);
--emphasis-bg-high-active: var(--color-gray-800);
--emphasis-border-color: var(--color-transparent);
--default-fg: var(--base-fg);
--default-bg-low-hover: var(--color-gray-100);
--default-bg-low-active: var(--color-gray-200);
--default-bg: var(--color-gray-600);
--default-bgr-high-hover: var(--color-gray-700);
--default-bg-high-active: var(--color-gray-800);
}

View File

@@ -1,25 +1,25 @@
@theme {
--filled-fg: var(--color-white);
--filled-fg-hover: var(--brand-fg);
--filled-fg-active: var(--brand-fg);
--filled-fg-hover: var(--color-white);
--filled-fg-active: var(--color-white);
--filled-bg: var(--brand-bg);
--filled-bg-hover: var(--brand-bg-high-hover);
--filled-bg-active: var(--brand-bg-high-active);
--filled-border-color: var(--brand-border-color);
--filled-border-color: var(--color-transparent);
--outline-fg: var(--brand-bg);
--outline-fg: var(--base-fg);
--outline-fg-hover: var(--brand-bg);
--outline-fg-active: var(--brand-bg);
--outline-bg: var(--brand-border-color);
--outline-bg: var(--color-transparent);
--outline-bg-hover: var(--brand-bg-low-hover);
--outline-bg-active: var(--brand-bg-low-active);
--outline-border-color: var(--brand-bg);
--subtle-fg: var(--brand-bg);
--subtle-fg: var(--base-fg);
--subtle-fg-hover: var(--brand-bg);
--subtle-fg-active: var(--brand-bg);
--subtle-bg: var(--brand-border-color);
--subtle-bg: var(--color-transparent);
--subtle-bg-hover: var(--brand-bg-low-hover);
--subtle-bg-active: var(--brand-bg-low-active);
--subtle-border-color: var(--brand-border-color);
--subtle-border-color: var(--color-transparent);
}

View File

@@ -1,45 +1,35 @@
@utility brand-info {
--brand-fg: var(--info-fg);
--brand-bg-low-hover: var(--info-bg-low-hover);
--brand-bg-low-active: var(--info-bg-low-active);
--brand-bg: var(--info-bg);
--brand-bg-high-hover: var(--info-bg-high-hover);
--brand-bg-high-active: var(--info-bg-high-active);
--brand-border-color: var(--info-border-color);
}
@utility brand-danger {
--brand-fg: var(--danger-fg);
--brand-bg-low-hover: var(--danger-bg-low-hover);
--brand-bg-low-active: var(--danger-bg-low-active);
--brand-bg: var(--danger-bg);
--brand-bg-high-hover: var(--danger-bg-high-hover);
--brand-bg-high-active: var(--danger-bg-high-active);
--brand-border-color: var(--danger-border-color);
}
@utility brand-success {
--brand-fg: var(--success-fg);
--brand-bg-low-hover: var(--success-bg-low-hover);
--brand-bg-low-active: var(--success-bg-low-active);
--brand-bg: var(--success-bg);
--brand-bg-high-hover: var(--success-bg-high-hover);
--brand-bg-high-active: var(--success-bg-high-active);
--brand-border-color: var(--success-border-color);
}
@utility brand-warning {
--brand-fg: var(--warning-fg);
--brand-bg-low-hover: var(--warning-bg-low-hover);
--brand-bg-low-active: var(--warning-bg-low-active);
--brand-bg: var(--warning-bg);
--brand-bg-high-hover: var(--warning-bg-high-hover);
--brand-bg-high-active: var(--warning-bg-high-active);
--brand-border-color: var(--warning-border-color);
}
@utility brand-emphasis {
--brand-fg: var(--emphasis-fg);
--brand-bg-low-hover: var(--emphasis-bg-low-hover);
--brand-bg-low-active: var(--emphasis-bg-low-active);
--brand-bg: var(--emphasis-bg);
--brand-bg-high-hover: var(--emphasis-bg-high-hover);
--brand-bg-high-active: var(--emphasis-bg-high-active);
--brand-border-color: var(--emphasis-border-color);
@utility brand-default {
--brand-bg-low-hover: var(--default-bg-low-hover);
--brand-bg-low-active: var(--default-bg-low-active);
--brand-bg: var(--default-bg);
--brand-bg-high-hover: var(--default-bg-high-hover);
--brand-bg-high-active: var(--default-bg-high-active);
}

View File

@@ -5,14 +5,17 @@
&:hover {
background-color: var(--filled-bg-hover);
color: var(--filid-fg-hover);
}
&:active {
background-color: var(--filled-bg-active);
color: var(--filid-fg-active);
}
&:focus-visible {
background-color: var(--filled-bg-hover);
color: var(--filid-fg-hover);
}
}
@@ -23,14 +26,17 @@
&:hover {
background-color: var(--outline-bg-hover);
color: var(--outline-fg-hover);
}
&:active {
background-color: var(--outline-bg-active);
color: var(--outline-fg-active);
}
&:focus-visible {
background-color: var(--outline-bg-hover);
color: var(--outline-fg-hover);
}
}
@@ -41,14 +47,17 @@
&:hover {
background-color: var(--subtle-bg-hover);
color: var(--subtle-fg-hover);
}
&:active {
background-color: var(--subtle-bg-active);
color: var(--subtle-fg-active);
}
&:focus-visible {
background-color: var(--subtle-bg-hover);
color: var(--subtle-fg-hover);
}
}