使用 Headless UI Framework 范式开发一个 UI 库

手动实现一个 Button 组件,核心使用 tailwind-mergetailwind-variantsreact-aria

这三个库构建了一个功能完善、可定制且具有良好无障碍性的按钮组件

先来处理 css

tailwind-merge

tailwind-merge 用于解决 Tailwind CSS 类名冲突问题。在组件开发中,我们经常需要合并来自不同来源的类名,例如组件默认样式和用户自定义样式。

1
2
3
4
5
6
7
8
// 在 mcx.ts 中的实现
import cx from 'classnames';
import { twMerge } from 'tailwind-merge';

// 结合 classnames 和 tailwind-merge
export const mcx = (...args: cx.ArgumentArray) => {
return twMerge(cx(args));
};

这个 mcx 函数首先使用 classnames 合并所有类名,然后通过 tailwind-merge 解决可能的冲突。例如,当同时指定 text-red-500text-blue-500 时,它会确保只有后者生效。

在 Button 组件中的应用:

1
className: mcx(slots.base({ class: classNames?.base }), className)

优先使用组件默认样式,但允许用户通过 className 属性覆盖默认样式。

tailwind-variants

tailwind-variants 提供了一种声明式的方式来定义组件在不同状态和属性下的样式变体。它使得组件能够根据传入的属性动态改变外观。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
import { ComponentPropsWithRef, PropsWithChildren } from "react";
import { VariantProps, tv } from "tailwind-variants";

// 在 button.tv.ts 中的定义
const ButtonVariant = tv({
slots: {
base: [
"group/button",
"border",
"relative",
// 其他基础样式...
],
icon: "",
svgIcon: "",
text: "truncate whitespace-pre",
spinner: "absolute animate-spin",
},
variants: {
{ /* 对齐方式 */ }
alignment: {
left: {
base: "justify-start text-left",
},
center: {
base: "justify-center text-center",
},
right: {
base: "justify-end text-right",
},
},
{ /* 尺寸 */ }
setSize: {
none: "",
xs: {
base: "h-6 min-w-6 px-2 text-xs rounded-xs gap-1",
icon: "w-3 h-3 -ml-0.5",
},
sm: {
base: "h-8 min-w-8 px-3 text-sm rounded-sm gap-1.5",
icon: "w-3.5 h-3.5 -ml-0.75",
},
md: {
base: "h-10 min-w-10 px-4 text-base rounded gap-2",
icon: "w-4 h-4 -ml-1",
},
lg: {
base: "h-12 min-w-12 px-5 text-lg rounded-lg gap-2.5",
icon: "w-4.5 h-4.5 -ml-1.25",
},
},
intent: {
none: "",
default: "",
primary: "",
success: "",
danger: "",
dynamic: "",
violet: "",
},
{ /* 样式 */ }
variant: {
none: {
base: "",
},
default: {
base: [
style["button"],
"data-[hover=true]:border-transparent",
"data-[pressed=true]:border-transparent",
"data-[active=true]:border-transparent",
],
},
outlined: {
base: [],
},
solid: {
base: [],
},
minimal: {
base: [],
},
fade: {
base: [],
},
},
isActive: {
true: "",
false: "",
},
isDisabled: {
true: {
base: "cursor-not-allowed",
},
false: {
base: "cursor-pointer",
},
},
isLoading: {
true: {
base: "pointer-events-none",
icon: "invisible",
svgIcon: "invisible",
text: "invisible",
},
},
isDynamic: {
true: "",
false: "",
},
isIconOnly: {
false: {
base: "",
icon: "",
},
true: {
base: "px-0",
icon: "m-0",
},
},
disableAnimation: {
true: "transition-none",
false: "data-[pressed=true]:scale-[0.97] motion-reduce:transition-none",
},
},
compoundVariants: [
// intent: default - variant: default
{
intent: "default",
variant: "default",
class: {
base: [
"text-primary-invert bg-primary border-primary",
"data-[hover=true]:bg-primary/90",
"data-[pressed=true]:bg-primary/80",
"data-[active=true]:bg-primary/80",
],
},
},
// intent: default - variant: outlined
{
intent: "default",
variant: "outlined",
class: {
base: [
"border-light-200",
"data-[hover=true]:bg-light-100",
"data-[pressed=true]:bg-light-200",
"data-[active=true]:bg-light-200",
],
},
},
// intent: default - variant: solid
{
intent: "default",
variant: "solid",
class: {
base: [
"bg-fade-50 border-transparent",
"data-[hover=true]:bg-fade-100",
"data-[pressed=true]:bg-fade-200",
"data-[active=true]:bg-fade-200",
],
},
},
// intent: primary --------------------------------
// intent: primary - variant: default
{
intent: "primary",
variant: "default",
class: {
base: [
"text-white bg-accent border-accent",
"data-[hover=true]:bg-accent/90",
"data-[pressed=true]:bg-accent/80",
"data-[active=true]:bg-accent/80",
],
},
},
// intent: primary - variant: outlined
{
intent: "primary",
variant: "outlined",
class: {
base: [
"text-accent border-accent",
"data-[hover=true]:bg-accent/10",
"data-[pressed=true]:bg-accent/20",
"data-[active=true]:bg-accent/20",
],
},
},
// intent: primary - variant: solid
{
intent: "primary",
variant: "solid",
class: {
base: [
"bg-accent/10 text-accent border-transparent",
"data-[hover=true]:bg-accent/20",
"data-[pressed=true]:bg-accent/30",
"data-[active=true]:bg-accent/30",
],
},
},
// intent: success --------------------------------
// intent: success - variant: default
{
intent: "success",
variant: "default",
class: {
base: [
"text-white bg-success border-success",
"data-[hover=true]:bg-success/90",
"data-[pressed=true]:bg-success/80",
"data-[active=true]:bg-success/80",
],
},
},
// intent: success - variant: outlined
{
intent: "success",
variant: "outlined",
class: {
base: [
"text-success border-success",
"data-[hover=true]:bg-success/10",
"data-[pressed=true]:bg-success/20",
"data-[active=true]:bg-success/20",
],
},
},
// intent: success - variant: solid
{
intent: "success",
variant: "solid",
class: {
base: [
"bg-success/10 text-success border-transparent",
"data-[hover=true]:bg-success/20",
"data-[pressed=true]:bg-success/30",
"data-[active=true]:bg-success/30",
],
},
},
],
// 默认变量
defaultVariants: {
isActive: false,
isDisabled: false,
isLoading: false,
intent: "default",
variant: "default",
},
});

export type ButtonVariantProps = VariantProps<typeof ButtonVariant>;
export type ButtonSlots = keyof ReturnType<typeof ButtonVariant>;
export { ButtonVariant };
1
2
3
4
5
6
7
8
9
10
11
.button {
box-shadow: inset 0px 1px 0px 0px hsla(0, 0%, 100%, 0.2);
&::after {
content: "";
pointer-events: none;
position: absolute;
inset: 0;
border-radius: inherit;
background-image: linear-gradient(to bottom, hsla(0, 0%, 100%, 0.2), hsla(0, 0%, 100%, 0));
}
}

tv 函数接收配置并生成一个函数,该函数可以根据传入的属性返回相应的类名。这使得我们能够通过简单地更改属性值来改变按钮的外观。

开发组件 Hooks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
import * as React from "react";
import {
MouseEvent,
ReactNode,
useCallback,
useMemo,
} from "react";
import { AriaButtonProps, mergeProps, useFocusRing, useHover } from "react-aria";
import { ClassValue } from 'tailwind-variants';

export type SlotsToClasses<S extends string> = {
[key in S]?: ClassValue;
};

export type PropGetter<P = Record<string, unknown>> = (
props?: Merge<DOMAttributes, P>,
ref?: React.Ref<any>
) => React.RefAttributes<any>;

// 一些 Btn 的属性
type Props = {
classNames?: SlotsToClasses<ButtonSlots>;
icon?: string | JSX.Element;
text?: string | ReactNode;
spinner?: ReactNode;
disableAnimation?: boolean;
disableFocusRing?: boolean;
disableTabFocus?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
};

export function useButtonCore<E extends As>(props: Props) {
const {
alignment = "center",
as,
autoFocus,
className,
classNames,
icon,
intent = "default",
isActive = false,
isDisabled = false,
isLoading,
disableAnimation,
disableFocusRing,
disableTabFocus,
ref,
setSize = "md",
type = "button",
spinner,
text,
variant = "default",
onPress,
onPressStart,
onPressEnd,
onClick,
...rest
} = props;

const Component = (as || "button") as As;
const shouldFilterDOMProps = typeof Component === "string";

// 下面都是一下 Btn 组件用到的属性
// 大部分的逻辑交互由 react-aria 提供

// eslint-disable-next-line react-hooks/exhaustive-deps
const handleClick = async (e: MouseEvent<HTMLButtonElement>) => {
if (onClick) {
setLoading(true);
try {
await onClick?.(e);
setLoading(false);
} catch (error) {
setLoading(false);
}
}
};

const [innerLoading, setLoading] = useState(isLoading);

const { isFocusVisible, isFocused, focusProps } = useFocusRing({
autoFocus,
});

const { isHovered, hoverProps } = useHover({ isDisabled });

const isIconOnly = text === undefined;

const slots = useMemo(
() =>
ButtonVariant({
alignment,
intent: isDisabled ? "none" : intent,
isActive: isDisabled ? false : isActive,
isDisabled,
isDynamic: intent === "dynamic" ? true : false,
isIconOnly,
isLoading: innerLoading,
setSize,
variant,
disableAnimation,
}),
[
alignment,
innerLoading,
intent,
isActive,
isDisabled,
isIconOnly,
setSize,
variant,
disableAnimation,
],
);

// 整合 props
const getButtonProps = useCallback(
(props = {}) => ({
"data-slot": "base",
"data-group-slot": true,
className: mcx(slots.base({ class: classNames?.base }), className),
disabled: Component === "button" ? isDisabled : undefined,
autoFocus,
"aria-busy": innerLoading,
"aria-disabled": isDisabled,
"aria-label": text,
"data-active": isActive,
"data-disabled": isDisabled,
"data-focus": disableFocusRing ? false : isFocused,
"data-focus-visible": disableFocusRing ? false : isFocusVisible,
"data-hover": isActive ? false : isHovered,
"data-loading": innerLoading,

...mergeProps(
focusProps,
hoverProps,
),
onClick: handleClick,
tabIndex: disableTabFocus || isDisabled ? -1 : 0,
}),
[
slots,
classNames?.base,
className,
Component,
isDisabled,
autoFocus,
innerLoading,
text,
isActive,
disableFocusRing,
isFocused,
isFocusVisible,
isHovered,
focusProps,
hoverProps,
rest,
shouldFilterDOMProps,
handleClick,
disableTabFocus,
],
);

const getIconProps: PropGetter = useCallback(
(props = {}) => ({
...props,
className: mcx(slots.icon({ class: classNames?.icon }), icon),
"data-slot": "icon",
"aria-hidden": true,
tabIndex: -1,
focusable: false,
}),
[classNames?.icon, icon, slots],
);

const getSvgIconProps: PropGetter = useCallback(
(props = {}) => ({
...props,
className: slots.svgIcon({ class: classNames?.svgIcon }),
"data-slot": "svgIcon",
"aria-hidden": true,
tabIndex: -1,
focusable: false,
inert: "true",
}),
[classNames?.svgIcon, slots],
);

const getTextProps: PropGetter = useCallback(
(props = {}) => ({
...props,
className: slots.text({ class: classNames?.text }),
"data-slot": "text",
}),
[classNames?.text, slots],
);

const getSpinnerProps: PropGetter = useCallback(
(props = {}) => ({
...props,
className: slots.spinner({ class: classNames?.spinner }),
"data-slot": "spinner",
"aria-hidden": true,
tabIndex: -1,
}),
[classNames?.spinner, slots],
);

return {
Component,
getButtonProps,
getIconProps,
getSvgIconProps,
getTextProps,
getSpinnerProps,
innerLoading,
};
}

export type UseButtonReturn = ReturnType<typeof useButtonCore>;

开发组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import type { ReactNode } from "react";
import { forwardRef } from "react";
import { SvgIcon } from "../svg-icon";
import { SvgIconName, type As } from "../../utilities";
import { ButtonProps } from "./type";
import { UseButtonProps, useButtonCore } from "./use-button";

type ButtonComponent = <E extends As = "button">(props: ButtonProps<E>) => ReactNode | null;

export const Button: ButtonComponent = forwardRef(function Button<E extends As = "button">(
{ afterComponent, beforeComponent, children, ...props }: ButtonProps<E>,
ref: ButtonProps<E>["ref"],
) {
const {
Component,
getButtonProps,
getIconProps,
getSvgIconProps,
getTextProps,
getSpinnerProps,
innerLoading,
} = useButtonCore({ ...props, ref } as UseButtonProps<E>);

return (
<Component
{...getButtonProps()}
type={props.type ?? "button"}
>
{beforeComponent}
{props.icon &&
(typeof props.icon === "string" ? (
<div {...getIconProps()} />
) : (
<span {...getSvgIconProps()}>{props.icon}</span>
))}
{props.text && <span {...getTextProps()}>{props.text}</span>}
{afterComponent}
{children}
{innerLoading &&
(props.spinner || (
<div {...getSpinnerProps()}>
<SvgIcon name={SvgIconName.ui} />
</div>
))}
</Component>
);
});

使用示例

基本用法示例:

1
2
3
4
5
6
7
<Button 
variant="solid"
intent="primary"
setSize="lg"
icon={<CustomIcon />}
text="提交表单"
/>

Button 组件实现功能:

  1. tailwind-merge (mcx) 处理样式冲突,确保样式的一致性
  2. tailwind-variants (tv) 提供多样化的外观变化,增强组件的可定制性
  3. react-aria 提供无障碍支持,确保组件对所有用户都友好