@endfield-ui/react 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +55 -5
- package/dist/index.d.ts +204 -2
- package/dist/index.js +615 -91
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/README.md +8 -1
- package/src/components/avatar/Avatar.tsx +3 -0
- package/src/components/carousel/Carousel.tsx +7 -47
- package/src/components/carousel/snap-track.ts +74 -0
- package/src/components/hover-card/HoverCard.tsx +101 -0
- package/src/components/hover-card/index.ts +6 -0
- package/src/components/image-viewer/ImageViewer.tsx +427 -0
- package/src/components/image-viewer/index.ts +7 -0
- package/src/components/item-slot/ItemSlot.tsx +1 -0
- package/src/components/otp-input/OtpInput.tsx +211 -0
- package/src/components/otp-input/index.ts +7 -0
- package/src/components/page-header/PageHeader.tsx +182 -0
- package/src/components/page-header/index.ts +6 -0
- package/src/components/popover/Popover.tsx +4 -12
- package/src/components/popover/panel-style.ts +15 -0
- package/src/components/scroll-area/ScrollArea.tsx +137 -0
- package/src/components/scroll-area/index.ts +5 -0
- package/src/components/table/Table.tsx +1 -0
- package/src/components/tag-input/TagInput.tsx +1 -0
- package/src/components/toc/Toc.tsx +1 -0
- package/src/index.ts +5 -0
|
@@ -33,6 +33,7 @@ type OwnProps = {
|
|
|
33
33
|
/** 右上角的小圆位:已装备者的头像 */
|
|
34
34
|
badge?: ReactNode;
|
|
35
35
|
/** 售罄 / 不可用:整卡压一层遮罩。传文字("售罄")会写在遮罩上 */
|
|
36
|
+
// oxlint-disable-next-line typescript/no-redundant-type-constituents -- ReactNode 本来就含 boolean,写出来是让人一眼看到两种用法
|
|
36
37
|
unavailable?: boolean | ReactNode;
|
|
37
38
|
/** 传了就整格渲染成链接 */
|
|
38
39
|
href?: string;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { OTPField as BaseOtpField } from "@base-ui/react/otp-field";
|
|
2
|
+
import { Fragment, useId, type ComponentProps } from "react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
5
|
+
import { useFieldContext, useFieldControl } from "../field/Field";
|
|
6
|
+
import type { ControlSize, ControlVariant } from "../input/control-box";
|
|
7
|
+
|
|
8
|
+
export type OtpInputType = "numeric" | "alphanumeric";
|
|
9
|
+
export type OtpInputSize = ControlSize;
|
|
10
|
+
export type OtpInputVariant = ControlVariant;
|
|
11
|
+
|
|
12
|
+
export type OtpInputProps = Omit<
|
|
13
|
+
ComponentProps<"div">,
|
|
14
|
+
"onChange" | "defaultValue" | "children"
|
|
15
|
+
> & {
|
|
16
|
+
/** 一共几位,默认 6 */
|
|
17
|
+
length?: number;
|
|
18
|
+
/** 拼起来的那一串 */
|
|
19
|
+
value?: string;
|
|
20
|
+
defaultValue?: string;
|
|
21
|
+
onValueChange?: (value: string) => void;
|
|
22
|
+
/** 每一位都填上了。粘贴一整串也算 */
|
|
23
|
+
onComplete?: (value: string) => void;
|
|
24
|
+
/** 收哪些字符:`numeric` 只收数字,默认;`alphanumeric` 收字母和数字 */
|
|
25
|
+
type?: OtpInputType;
|
|
26
|
+
/** 字母统一成大写:值也是大写,不只是看起来 */
|
|
27
|
+
uppercase?: boolean;
|
|
28
|
+
/** 把填进去的字遮成圆点 */
|
|
29
|
+
mask?: boolean;
|
|
30
|
+
/** 每几格之间加一道短横,如 6 位的码写 `3` 分成两组。只是画的 */
|
|
31
|
+
groupSize?: number;
|
|
32
|
+
/** 32 / 40 / 56px 见方 */
|
|
33
|
+
size?: OtpInputSize;
|
|
34
|
+
/** 同输入框:`sunken` 凹陷的底 + 底边线,默认;`outline` 四边描边 */
|
|
35
|
+
variant?: OtpInputVariant;
|
|
36
|
+
/** 错误态:每一格的边线变红。放在 `Field` 里时跟随它的 `error` */
|
|
37
|
+
invalid?: boolean;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
readOnly?: boolean;
|
|
40
|
+
required?: boolean;
|
|
41
|
+
/** 传了就有一个隐藏字段随表单提交,值是拼起来的那一串 */
|
|
42
|
+
name?: string;
|
|
43
|
+
form?: string;
|
|
44
|
+
/** 填满就提交所在的表单 */
|
|
45
|
+
autoSubmit?: boolean;
|
|
46
|
+
/** 挂载后焦点落在第一格 */
|
|
47
|
+
autoFocus?: boolean;
|
|
48
|
+
/** 第二格起每一格的可访问名称,默认"第 2 位,共 6 位"。第一格的名称是字段的标签 */
|
|
49
|
+
slotLabel?: (position: number, length: number) => string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/* 格子见方;容器窄了宽度可以让,高度不变 */
|
|
53
|
+
const slotSize: Record<ControlSize, string> = {
|
|
54
|
+
sm: "h-8 w-8 text-base",
|
|
55
|
+
md: "h-10 w-10 text-lg",
|
|
56
|
+
lg: "h-14 w-14 text-2xl",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const slotFrame: Record<ControlVariant, string> = {
|
|
60
|
+
sunken: "border-b-2",
|
|
61
|
+
outline: "border",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const slotFill: Record<ControlVariant, string> = {
|
|
65
|
+
sunken: "bg-surface-sunken",
|
|
66
|
+
outline: "bg-surface",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const gapClass: Record<ControlSize, string> = {
|
|
70
|
+
sm: "gap-1.5",
|
|
71
|
+
md: "gap-2",
|
|
72
|
+
lg: "gap-2",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const defaultSlotLabel = (position: number, length: number) =>
|
|
76
|
+
`第 ${position} 位,共 ${length} 位`;
|
|
77
|
+
|
|
78
|
+
const toUpperCase = (value: string) => value.toUpperCase();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 验证码输入:一格一位的短码。每一格是一个真的输入框——打一位跳一格,
|
|
82
|
+
* 粘贴一整串会分到各格,整组在 Tab 顺序里只停一次。
|
|
83
|
+
*
|
|
84
|
+
* 每个都要有标签:放进 `Field`,或者自己传 `aria-label`。
|
|
85
|
+
* `className` 与 `ref` 给外面那一排。
|
|
86
|
+
*/
|
|
87
|
+
export function OtpInput({
|
|
88
|
+
length = 6,
|
|
89
|
+
value,
|
|
90
|
+
defaultValue,
|
|
91
|
+
onValueChange,
|
|
92
|
+
onComplete,
|
|
93
|
+
type = "numeric",
|
|
94
|
+
uppercase = false,
|
|
95
|
+
mask = false,
|
|
96
|
+
groupSize,
|
|
97
|
+
size = "md",
|
|
98
|
+
variant = "sunken",
|
|
99
|
+
invalid: invalidProp,
|
|
100
|
+
id: idProp,
|
|
101
|
+
disabled: disabledProp,
|
|
102
|
+
required: requiredProp,
|
|
103
|
+
readOnly = false,
|
|
104
|
+
name,
|
|
105
|
+
form,
|
|
106
|
+
autoSubmit = false,
|
|
107
|
+
autoFocus = false,
|
|
108
|
+
slotLabel = defaultSlotLabel,
|
|
109
|
+
"aria-label": ariaLabel,
|
|
110
|
+
"aria-labelledby": ariaLabelledBy,
|
|
111
|
+
"aria-describedby": describedByProp,
|
|
112
|
+
className,
|
|
113
|
+
...props
|
|
114
|
+
}: OtpInputProps) {
|
|
115
|
+
const fieldContext = useFieldContext();
|
|
116
|
+
const { id, disabled, required, invalid, ...field } = useFieldControl({
|
|
117
|
+
id: idProp,
|
|
118
|
+
disabled: disabledProp,
|
|
119
|
+
required: requiredProp,
|
|
120
|
+
invalid: invalidProp,
|
|
121
|
+
"aria-describedby": describedByProp,
|
|
122
|
+
});
|
|
123
|
+
const describedBy = field["aria-describedby"];
|
|
124
|
+
|
|
125
|
+
// 基元不认第一格上的 aria-label(它要一个真的标签):自己传名称时,
|
|
126
|
+
// 把它写成一段隐藏的文字,让第一格和整组都指过去
|
|
127
|
+
const labelId = useId();
|
|
128
|
+
const ownLabel = ariaLabelledBy ?? (ariaLabel ? labelId : undefined);
|
|
129
|
+
const groupLabel = ownLabel ?? fieldContext?.labelId;
|
|
130
|
+
|
|
131
|
+
const slotClass = cn(
|
|
132
|
+
"min-w-0 shrink text-center font-tech font-medium tabular-nums text-ink caret-ink",
|
|
133
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
134
|
+
focusRing,
|
|
135
|
+
slotSize[size],
|
|
136
|
+
slotFrame[variant],
|
|
137
|
+
readOnly
|
|
138
|
+
? // 只读:没有边线、底变透明。边线留透明的,尺寸不跳
|
|
139
|
+
"border-transparent bg-transparent"
|
|
140
|
+
: slotFill[variant],
|
|
141
|
+
disabled
|
|
142
|
+
? "cursor-not-allowed border-line text-ink-disabled"
|
|
143
|
+
: invalid
|
|
144
|
+
? "border-danger"
|
|
145
|
+
: !readOnly &&
|
|
146
|
+
// 悬停只在没聚焦时生效:聚焦中的格子不会因为鼠标经过而变浅
|
|
147
|
+
"border-line-strong hover:not-focus:border-ink-secondary focus:border-ink",
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const group = groupSize !== undefined && groupSize > 0 ? groupSize : 0;
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<BaseOtpField.Root
|
|
154
|
+
{...props}
|
|
155
|
+
id={id}
|
|
156
|
+
length={length}
|
|
157
|
+
value={value}
|
|
158
|
+
defaultValue={defaultValue}
|
|
159
|
+
onValueChange={onValueChange && ((next) => onValueChange(next))}
|
|
160
|
+
onValueComplete={onComplete && ((next) => onComplete(next))}
|
|
161
|
+
validationType={type}
|
|
162
|
+
normalizeValue={uppercase ? toUpperCase : undefined}
|
|
163
|
+
mask={mask}
|
|
164
|
+
disabled={disabled}
|
|
165
|
+
readOnly={readOnly}
|
|
166
|
+
required={required}
|
|
167
|
+
name={name}
|
|
168
|
+
form={form}
|
|
169
|
+
autoSubmit={autoSubmit}
|
|
170
|
+
aria-labelledby={groupLabel}
|
|
171
|
+
aria-describedby={describedBy}
|
|
172
|
+
data-size={size}
|
|
173
|
+
data-variant={variant}
|
|
174
|
+
data-invalid={invalid ? "" : undefined}
|
|
175
|
+
className={cn(
|
|
176
|
+
// 不许比容器宽:放在"靠左对齐"的弹性容器里时它是按内容定宽的,
|
|
177
|
+
// 没有这一条格子就不会收窄,而是整排伸出去
|
|
178
|
+
"flex max-w-full min-w-0 items-center",
|
|
179
|
+
gapClass[size],
|
|
180
|
+
className,
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{ariaLabel && !ariaLabelledBy && (
|
|
184
|
+
<span id={labelId} hidden>
|
|
185
|
+
{ariaLabel}
|
|
186
|
+
</span>
|
|
187
|
+
)}
|
|
188
|
+
{Array.from({ length }, (_, index) => (
|
|
189
|
+
<Fragment key={index}>
|
|
190
|
+
{group > 0 && index > 0 && index % group === 0 && (
|
|
191
|
+
<span
|
|
192
|
+
aria-hidden="true"
|
|
193
|
+
data-separator=""
|
|
194
|
+
className="h-0.5 w-2 shrink-0 bg-line-strong"
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
<BaseOtpField.Input
|
|
198
|
+
// 第一格的名称是字段的标签(或者上面那段隐藏的文字),说明文字也挂在它上面
|
|
199
|
+
{...(index === 0
|
|
200
|
+
? { "aria-labelledby": ownLabel, "aria-describedby": describedBy }
|
|
201
|
+
: { "aria-label": slotLabel(index + 1, length) })}
|
|
202
|
+
aria-invalid={invalid || undefined}
|
|
203
|
+
autoFocus={autoFocus && index === 0}
|
|
204
|
+
autoCapitalize={uppercase ? "characters" : "none"}
|
|
205
|
+
className={slotClass}
|
|
206
|
+
/>
|
|
207
|
+
</Fragment>
|
|
208
|
+
))}
|
|
209
|
+
</BaseOtpField.Root>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { ComponentProps, MouseEvent, ReactNode } from "react";
|
|
2
|
+
import { ArrowLeft } from "../../icons/ArrowLeft";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
5
|
+
import { LinkElement, type LinkRender } from "../link-element/LinkElement";
|
|
6
|
+
|
|
7
|
+
export type PageHeaderProps = Omit<ComponentProps<"header">, "title"> & {
|
|
8
|
+
/** 页面的标题 */
|
|
9
|
+
title: ReactNode;
|
|
10
|
+
/** 标题的层级,默认 1(`<h1>`)。页头用在抽屉、分栏里时改小 */
|
|
11
|
+
level?: 1 | 2 | 3;
|
|
12
|
+
/** 标题上面的微文字行,如 `// ARCHIVE 共 128 条` */
|
|
13
|
+
meta?: ReactNode;
|
|
14
|
+
/** 标题下面的一段说明 */
|
|
15
|
+
description?: ReactNode;
|
|
16
|
+
/** 最上面的层级路径:放一个 `Breadcrumb` */
|
|
17
|
+
breadcrumb?: ReactNode;
|
|
18
|
+
/** 标题左边的返回:放一个 `PageHeaderBack` */
|
|
19
|
+
back?: ReactNode;
|
|
20
|
+
/** 右边的行动区:几个按钮。放不下时折到标题下面 */
|
|
21
|
+
actions?: ReactNode;
|
|
22
|
+
/** 底下加一条分隔线 */
|
|
23
|
+
divider?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* 吸在滚动容器的顶上,带实底和底下那条线。
|
|
26
|
+
* 上面还有吸顶的顶栏时用 `className` 改 `top`
|
|
27
|
+
*/
|
|
28
|
+
sticky?: boolean;
|
|
29
|
+
/** 标题下面的一行:页签、几个统计块 */
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 页头:这是哪一页、怎么回去、在这一页能做什么。一页一个。
|
|
35
|
+
* 按自身的宽度响应:行动区放不下时折到标题下面。
|
|
36
|
+
*/
|
|
37
|
+
export function PageHeader({
|
|
38
|
+
title,
|
|
39
|
+
level = 1,
|
|
40
|
+
meta,
|
|
41
|
+
description,
|
|
42
|
+
breadcrumb,
|
|
43
|
+
back,
|
|
44
|
+
actions,
|
|
45
|
+
divider = false,
|
|
46
|
+
sticky = false,
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
...props
|
|
50
|
+
}: PageHeaderProps) {
|
|
51
|
+
const Heading = `h${level}` as const;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<header
|
|
55
|
+
{...props}
|
|
56
|
+
data-sticky={sticky ? "" : undefined}
|
|
57
|
+
className={cn(
|
|
58
|
+
"@container flex flex-col gap-4 text-ink",
|
|
59
|
+
divider && !sticky && "border-b border-line pb-6",
|
|
60
|
+
sticky &&
|
|
61
|
+
"sticky top-0 z-(--z-float) border-b border-line bg-surface py-4",
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
{breadcrumb}
|
|
66
|
+
<div className="flex flex-col gap-1">
|
|
67
|
+
{meta && <p className="font-tech text-xs text-ink-secondary">{meta}</p>}
|
|
68
|
+
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-4">
|
|
69
|
+
{/* 标题这一组至少留 16rem:行动区挤不进来就整块折下去,而不是把标题挤成一个字一行 */}
|
|
70
|
+
<div className="flex min-w-0 flex-1 basis-64 items-start gap-4">
|
|
71
|
+
{/*
|
|
72
|
+
* 标题的一行是 40px 高(宽了是 48px)。返回和行动区各放在同样高的一格里居中,
|
|
73
|
+
* 所以它们对的是标题的第一行——标题折成几行、下面有没有说明都一样
|
|
74
|
+
*/}
|
|
75
|
+
{back && (
|
|
76
|
+
<div className="flex h-10 shrink-0 items-center @md:h-12">
|
|
77
|
+
{back}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
81
|
+
<Heading className="min-w-0 text-2xl leading-10 font-bold wrap-anywhere @md:text-3xl @md:leading-12">
|
|
82
|
+
{title}
|
|
83
|
+
</Heading>
|
|
84
|
+
{description && (
|
|
85
|
+
<p className="max-w-prose text-sm text-ink-secondary">
|
|
86
|
+
{description}
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
{actions && (
|
|
92
|
+
<div
|
|
93
|
+
data-actions=""
|
|
94
|
+
className="flex min-h-10 flex-wrap items-center gap-2 @md:min-h-12"
|
|
95
|
+
>
|
|
96
|
+
{actions}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
{children}
|
|
102
|
+
</header>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type BackOwnProps = {
|
|
107
|
+
/** 默认"返回"。建议写成"返回某某" */
|
|
108
|
+
"aria-label"?: string;
|
|
109
|
+
/** 传了就渲染成链接 */
|
|
110
|
+
href?: string;
|
|
111
|
+
target?: string;
|
|
112
|
+
rel?: string;
|
|
113
|
+
/** 用这个元素代替 `<a>`(路由库的链接组件)。传了就按链接处理 */
|
|
114
|
+
render?: LinkRender;
|
|
115
|
+
onClick?: (event: MouseEvent<HTMLElement>) => void;
|
|
116
|
+
disabled?: boolean;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type PageHeaderBackProps = BackOwnProps &
|
|
120
|
+
Omit<ComponentProps<"button">, keyof BackOwnProps | "type" | "children">;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 页头里的返回:40px 的深灰斜纹方块加一个左箭头,`back` 按钮收成的图标形态。
|
|
124
|
+
* 放进 `PageHeader` 的 `back`。
|
|
125
|
+
*/
|
|
126
|
+
export function PageHeaderBack({
|
|
127
|
+
"aria-label": label = "返回",
|
|
128
|
+
href,
|
|
129
|
+
target,
|
|
130
|
+
rel,
|
|
131
|
+
render,
|
|
132
|
+
onClick,
|
|
133
|
+
disabled = false,
|
|
134
|
+
className,
|
|
135
|
+
...props
|
|
136
|
+
}: PageHeaderBackProps) {
|
|
137
|
+
const classes = cn(
|
|
138
|
+
"relative inline-flex size-10 shrink-0 items-center justify-center",
|
|
139
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
140
|
+
focusRing,
|
|
141
|
+
disabled
|
|
142
|
+
? "cursor-not-allowed bg-disabled text-on-disabled"
|
|
143
|
+
: // 有意不随主题翻转:和 back 按钮是同一块深灰
|
|
144
|
+
"hatch hatch-mid bg-control text-white shadow-xs hover:bg-neutral-600 active:bg-control-pressed",
|
|
145
|
+
className,
|
|
146
|
+
);
|
|
147
|
+
const icon = <ArrowLeft size={20} />;
|
|
148
|
+
|
|
149
|
+
if (href !== undefined || render !== undefined) {
|
|
150
|
+
return (
|
|
151
|
+
<LinkElement
|
|
152
|
+
render={render}
|
|
153
|
+
// 禁用的链接去掉 href,保留 link 角色,让读屏仍能读出"不可用"
|
|
154
|
+
href={disabled ? undefined : href}
|
|
155
|
+
role={disabled ? "link" : undefined}
|
|
156
|
+
aria-disabled={disabled || undefined}
|
|
157
|
+
aria-label={label}
|
|
158
|
+
target={target}
|
|
159
|
+
rel={rel}
|
|
160
|
+
onClick={disabled ? (event) => event.preventDefault() : onClick}
|
|
161
|
+
data-page-header-back=""
|
|
162
|
+
className={classes}
|
|
163
|
+
>
|
|
164
|
+
{icon}
|
|
165
|
+
</LinkElement>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<button
|
|
171
|
+
{...props}
|
|
172
|
+
type="button"
|
|
173
|
+
aria-label={label}
|
|
174
|
+
disabled={disabled}
|
|
175
|
+
onClick={onClick}
|
|
176
|
+
data-page-header-back=""
|
|
177
|
+
className={classes}
|
|
178
|
+
>
|
|
179
|
+
{icon}
|
|
180
|
+
</button>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -2,7 +2,7 @@ import { Popover as BasePopover } from "@base-ui/react/popover";
|
|
|
2
2
|
import type { ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
3
|
import { usePortalScope } from "../../hooks/usePortalScope";
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
|
-
import {
|
|
5
|
+
import { popoverDescription, popoverPanel, popoverTitle } from "./panel-style";
|
|
6
6
|
|
|
7
7
|
export type PopoverSide = "top" | "bottom" | "left" | "right";
|
|
8
8
|
export type PopoverAlign = "start" | "center" | "end";
|
|
@@ -63,24 +63,16 @@ export function Popover({
|
|
|
63
63
|
sideOffset={4}
|
|
64
64
|
className="z-(--z-overlay) outline-none"
|
|
65
65
|
>
|
|
66
|
-
<BasePopover.Popup
|
|
67
|
-
{...props}
|
|
68
|
-
className={cn(
|
|
69
|
-
menuPanel,
|
|
70
|
-
// 面板和下拉菜单是同一份画法,只把上下 4px 的内边距换成四周 16px
|
|
71
|
-
"flex w-max max-w-[min(20rem,var(--available-width))] flex-col gap-3 p-4 text-sm wrap-anywhere",
|
|
72
|
-
className,
|
|
73
|
-
)}
|
|
74
|
-
>
|
|
66
|
+
<BasePopover.Popup {...props} className={cn(popoverPanel, className)}>
|
|
75
67
|
{(title || description) && (
|
|
76
68
|
<div className="flex flex-col gap-1">
|
|
77
69
|
{title && (
|
|
78
|
-
<BasePopover.Title className=
|
|
70
|
+
<BasePopover.Title className={popoverTitle}>
|
|
79
71
|
{title}
|
|
80
72
|
</BasePopover.Title>
|
|
81
73
|
)}
|
|
82
74
|
{description && (
|
|
83
|
-
<BasePopover.Description className=
|
|
75
|
+
<BasePopover.Description className={popoverDescription}>
|
|
84
76
|
{description}
|
|
85
77
|
</BasePopover.Description>
|
|
86
78
|
)}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { menuPanel } from "../dropdown-menu/menu-style";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 气泡卡片与悬浮卡共用的面板:下拉菜单的那一块,只把上下 4px 的内边距换成四周 16px。
|
|
5
|
+
* 宽度跟着内容走,最宽 320px,也不超过视口里剩下的地方。
|
|
6
|
+
* 要经过 `cn()`:后面的 `p-4` 要盖掉菜单面板自带的上下内边距
|
|
7
|
+
*/
|
|
8
|
+
export const popoverPanel = [
|
|
9
|
+
menuPanel,
|
|
10
|
+
"flex w-max max-w-[min(20rem,var(--available-width))] flex-col gap-3 p-4 text-sm wrap-anywhere",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** 面板里的标题和它下面的一行说明 */
|
|
14
|
+
export const popoverTitle = "text-base font-bold";
|
|
15
|
+
export const popoverDescription = "text-ink-secondary";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { ScrollArea as BaseScrollArea } from "@base-ui/react/scroll-area";
|
|
2
|
+
import type { ComponentProps, CSSProperties, Ref } from "react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { focusRingInset } from "../../lib/focus-ring";
|
|
5
|
+
|
|
6
|
+
export type ScrollAreaOrientation = "vertical" | "horizontal" | "both";
|
|
7
|
+
|
|
8
|
+
export type ScrollAreaProps = Omit<
|
|
9
|
+
ComponentProps<"div">,
|
|
10
|
+
"aria-label" | "className" | "style" | "ref"
|
|
11
|
+
> & {
|
|
12
|
+
/** 这一块是什么。能滚的时候它是一个能聚焦的区域,读屏听到的就是这个名称 */
|
|
13
|
+
"aria-label": string;
|
|
14
|
+
/** 哪个方向能滚,默认 `vertical`。只管一个方向时,另一个方向滚不了 */
|
|
15
|
+
orientation?: ScrollAreaOrientation;
|
|
16
|
+
/** 真正在滚的那个元素:给页内目录、回到顶部的 `target` */
|
|
17
|
+
viewportRef?: Ref<HTMLDivElement>;
|
|
18
|
+
/** 给外框:高度或者高度上限写在这里 */
|
|
19
|
+
className?: string;
|
|
20
|
+
style?: CSSProperties;
|
|
21
|
+
/** 给外框 */
|
|
22
|
+
ref?: Ref<HTMLDivElement>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* 滚动条:12px 宽的一条是能抓、能点的范围;正中一条 1px 的轨,
|
|
27
|
+
* 滑块是压在轨上的一段 3px 的粗条——和页内目录、侧轨二级的引线是同一个画法
|
|
28
|
+
*/
|
|
29
|
+
const barClass: Record<"vertical" | "horizontal", string> = {
|
|
30
|
+
vertical:
|
|
31
|
+
"w-3 before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-line",
|
|
32
|
+
horizontal:
|
|
33
|
+
"h-3 before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-line",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const thumbClass: Record<"vertical" | "horizontal", string> = {
|
|
37
|
+
vertical:
|
|
38
|
+
"w-full before:inset-y-0 before:left-1/2 before:w-[3px] before:-translate-x-1/2",
|
|
39
|
+
horizontal:
|
|
40
|
+
"h-full before:inset-x-0 before:top-1/2 before:h-[3px] before:-translate-y-1/2",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function Scrollbar({
|
|
44
|
+
orientation,
|
|
45
|
+
}: {
|
|
46
|
+
orientation: "vertical" | "horizontal";
|
|
47
|
+
}) {
|
|
48
|
+
return (
|
|
49
|
+
<BaseScrollArea.Scrollbar
|
|
50
|
+
orientation={orientation}
|
|
51
|
+
className={cn("group/bar flex", barClass[orientation])}
|
|
52
|
+
>
|
|
53
|
+
<BaseScrollArea.Thumb
|
|
54
|
+
className={cn(
|
|
55
|
+
"relative before:absolute before:bg-ink-secondary before:content-['']",
|
|
56
|
+
"before:transition-colors before:duration-(--duration-fast) before:ease-standard",
|
|
57
|
+
"group-hover/bar:before:bg-ink active:before:bg-ink",
|
|
58
|
+
// 高对比模式下背景色会被系统拿掉:换成系统的文字色,滑块才看得见
|
|
59
|
+
"forced-colors:before:bg-[CanvasText]",
|
|
60
|
+
thumbClass[orientation],
|
|
61
|
+
)}
|
|
62
|
+
/>
|
|
63
|
+
</BaseScrollArea.Scrollbar>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 滚动区:一块自己能滚的区域,滚动条是画出来的。高度(或高度上限)由 `className` 给。
|
|
69
|
+
* 只有真的溢出时才有滚动条,也才是一个能聚焦的区域。
|
|
70
|
+
*
|
|
71
|
+
* 整页的滚动留给浏览器,不要把页面主体包进来。
|
|
72
|
+
* `className` 与 `ref` 给外框,其余属性(`onScroll` 等)给真正在滚的可视区。
|
|
73
|
+
*/
|
|
74
|
+
export function ScrollArea({
|
|
75
|
+
"aria-label": label,
|
|
76
|
+
orientation = "vertical",
|
|
77
|
+
viewportRef,
|
|
78
|
+
className,
|
|
79
|
+
style,
|
|
80
|
+
ref,
|
|
81
|
+
children,
|
|
82
|
+
...props
|
|
83
|
+
}: ScrollAreaProps) {
|
|
84
|
+
const vertical = orientation !== "horizontal";
|
|
85
|
+
const horizontal = orientation !== "vertical";
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<BaseScrollArea.Root
|
|
89
|
+
ref={ref}
|
|
90
|
+
style={style}
|
|
91
|
+
data-scroll-area=""
|
|
92
|
+
data-orientation={orientation}
|
|
93
|
+
// 给了高度上限时内容短就跟着矮:外框是一个竖排的弹性容器,可视区在里面收缩
|
|
94
|
+
className={cn("flex min-h-0 min-w-0 flex-col text-ink", className)}
|
|
95
|
+
>
|
|
96
|
+
<BaseScrollArea.Viewport
|
|
97
|
+
{...props}
|
|
98
|
+
ref={viewportRef}
|
|
99
|
+
// 不管的那个方向不让滚(基元默认两个方向都是 scroll)
|
|
100
|
+
style={
|
|
101
|
+
orientation === "vertical"
|
|
102
|
+
? { overflowX: "hidden" }
|
|
103
|
+
: orientation === "horizontal"
|
|
104
|
+
? { overflowY: "hidden" }
|
|
105
|
+
: undefined
|
|
106
|
+
}
|
|
107
|
+
className={cn(
|
|
108
|
+
"min-h-0 flex-auto",
|
|
109
|
+
focusRingInset,
|
|
110
|
+
// 溢出的那一侧让出滚动条的位置,内容不会被压住。让的是可视区自己的外边距:
|
|
111
|
+
// 写成内边距的话,它在滚动容器里是"滚到头才有的留白",途中内容照样被压住
|
|
112
|
+
vertical && "data-has-overflow-y:mr-3",
|
|
113
|
+
horizontal && "data-has-overflow-x:mb-3",
|
|
114
|
+
)}
|
|
115
|
+
render={(viewportProps, state) => {
|
|
116
|
+
const scrollable =
|
|
117
|
+
(vertical && state.hasOverflowY) ||
|
|
118
|
+
(horizontal && state.hasOverflowX);
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
{...viewportProps}
|
|
122
|
+
// 只有真的能滚时才是一个有名称、能聚焦的区域:键盘要能滚它,
|
|
123
|
+
// 但不溢出的时候不该白占一个 Tab 停靠点
|
|
124
|
+
{...(scrollable && { role: "region", "aria-label": label })}
|
|
125
|
+
tabIndex={scrollable ? 0 : -1}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<BaseScrollArea.Content>{children}</BaseScrollArea.Content>
|
|
131
|
+
</BaseScrollArea.Viewport>
|
|
132
|
+
{vertical && <Scrollbar orientation="vertical" />}
|
|
133
|
+
{horizontal && <Scrollbar orientation="horizontal" />}
|
|
134
|
+
{orientation === "both" && <BaseScrollArea.Corner />}
|
|
135
|
+
</BaseScrollArea.Root>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -313,6 +313,7 @@ function DetailRow({
|
|
|
313
313
|
const [measured, setMeasured] = useState<number | null>(null);
|
|
314
314
|
|
|
315
315
|
// 每次渲染都重新数:列是使用方的,随时可能多一列少一列
|
|
316
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps -- 不写依赖是有意的;数出来没变时 setMeasured 不会再引出一次渲染
|
|
316
317
|
useLayoutEffect(() => {
|
|
317
318
|
const row = rowRef.current;
|
|
318
319
|
if (!row) return;
|
|
@@ -287,6 +287,7 @@ export function TagInput({
|
|
|
287
287
|
onKeyDown?.(event);
|
|
288
288
|
if (event.defaultPrevented || readOnly) return;
|
|
289
289
|
// 输入法正在组字时的回车是在选字,不是提交
|
|
290
|
+
// oxlint-disable-next-line typescript/no-deprecated -- Safari 上确认选字的那一下回车,isComposing 已经是 false,只有 keyCode 还是 229
|
|
290
291
|
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
291
292
|
const input = event.currentTarget;
|
|
292
293
|
|
|
@@ -99,6 +99,7 @@ export function Toc({
|
|
|
99
99
|
return active;
|
|
100
100
|
},
|
|
101
101
|
// count 没在里面用到:它变了这个函数就换一个,滚动的监听跟着重新接、重新算一次
|
|
102
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
102
103
|
[target, offset, count],
|
|
103
104
|
);
|
|
104
105
|
const spied = useScrollPosition<string | null>(target, read, null);
|
package/src/index.ts
CHANGED
|
@@ -28,7 +28,9 @@ export * from "./components/flyout-bar";
|
|
|
28
28
|
export * from "./components/ghost-text";
|
|
29
29
|
export * from "./components/hatch";
|
|
30
30
|
export * from "./components/hazard-stripe";
|
|
31
|
+
export * from "./components/hover-card";
|
|
31
32
|
export * from "./components/icon-button";
|
|
33
|
+
export * from "./components/image-viewer";
|
|
32
34
|
export * from "./components/input";
|
|
33
35
|
export * from "./components/item-slot";
|
|
34
36
|
export * from "./components/kbd";
|
|
@@ -39,6 +41,8 @@ export * from "./components/media-card";
|
|
|
39
41
|
export * from "./components/nav-action";
|
|
40
42
|
export * from "./components/nav-menu";
|
|
41
43
|
export * from "./components/navigator";
|
|
44
|
+
export * from "./components/otp-input";
|
|
45
|
+
export * from "./components/page-header";
|
|
42
46
|
export * from "./components/pagination";
|
|
43
47
|
export * from "./components/panel";
|
|
44
48
|
export * from "./components/play-button";
|
|
@@ -48,6 +52,7 @@ export * from "./components/radio";
|
|
|
48
52
|
export * from "./components/rec-indicator";
|
|
49
53
|
export * from "./components/registration-strip";
|
|
50
54
|
export * from "./components/schedule";
|
|
55
|
+
export * from "./components/scroll-area";
|
|
51
56
|
export * from "./components/scroll-hint";
|
|
52
57
|
export * from "./components/section-title";
|
|
53
58
|
export * from "./components/segmented-control";
|