@endfield-ui/react 0.2.0 → 1.0.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/NOTICE.md +2 -2
- package/README.md +103 -8
- package/dist/index.d.ts +78 -17
- package/dist/index.js +1184 -264
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/README.md +11 -2
- package/src/components/alert/Alert.tsx +63 -1
- package/src/components/avatar/Avatar.tsx +5 -2
- package/src/components/bracket-title/BracketTitle.tsx +32 -3
- package/src/components/button/Button.tsx +37 -5
- package/src/components/calendar/Calendar.tsx +49 -5
- package/src/components/checkbox/Checkbox.tsx +1 -0
- package/src/components/checkbox/choice-style.ts +13 -5
- package/src/components/chip/capsule-style.ts +18 -3
- package/src/components/combobox/Combobox.tsx +10 -0
- package/src/components/completion-banner/CompletionBanner.tsx +30 -2
- package/src/components/corner-brackets/CornerBrackets.tsx +2 -1
- package/src/components/dash-indicator/DashIndicator.tsx +5 -2
- package/src/components/data-row/DataRow.tsx +2 -1
- package/src/components/date-picker/DateField.tsx +5 -1
- package/src/components/dialog/Dialog.tsx +4 -2
- package/src/components/dropdown-menu/MenuCheck.tsx +4 -2
- package/src/components/dropdown-menu/menu-style.ts +22 -5
- package/src/components/empty-state/EmptyState.tsx +5 -0
- package/src/components/field/Field.tsx +39 -7
- package/src/components/file-upload/FileUpload.tsx +79 -20
- package/src/components/icon-button/IconButton.tsx +18 -5
- package/src/components/image-viewer/ImageViewer.tsx +58 -18
- package/src/components/input/control-box.ts +23 -2
- package/src/components/input/value-chip.ts +3 -2
- package/src/components/item-slot/ItemSlot.tsx +2 -1
- package/src/components/loader/Loader.tsx +12 -41
- package/src/components/nav-menu/NavMenu.tsx +17 -3
- package/src/components/otp-input/OtpInput.tsx +13 -4
- package/src/components/page-header/PageHeader.tsx +39 -3
- package/src/components/pagination/Pagination.tsx +2 -0
- package/src/components/progress/Progress.tsx +10 -3
- package/src/components/progress/ProgressRing.tsx +19 -4
- package/src/components/radio/Radio.tsx +2 -0
- package/src/components/rolling-number/RollingNumber.tsx +125 -0
- package/src/components/rolling-number/index.ts +1 -0
- package/src/components/schedule/Schedule.tsx +5 -3
- package/src/components/segmented-control/SegmentedControl.tsx +25 -4
- package/src/components/skeleton/Skeleton.tsx +4 -1
- package/src/components/slider/Slider.tsx +26 -5
- package/src/components/sparkline/Sparkline.tsx +29 -5
- package/src/components/spinner/Spinner.tsx +6 -2
- package/src/components/stepper/Stepper.tsx +4 -0
- package/src/components/steps/Steps.tsx +30 -9
- package/src/components/switch/Switch.tsx +26 -2
- package/src/components/table/Table.tsx +33 -9
- package/src/components/tabs/Tabs.tsx +55 -9
- package/src/components/tag-input/TagInput.tsx +82 -43
- package/src/components/textarea/Textarea.tsx +2 -1
- package/src/components/timeline/Timeline.tsx +4 -3
- package/src/components/toast/Toast.tsx +18 -5
- package/src/components/toc/Toc.tsx +89 -7
- package/src/components/toolbar/Toolbar.tsx +24 -0
- package/src/components/tooltip/Tooltip.tsx +5 -1
- package/src/components/viewfinder/Viewfinder.tsx +9 -0
- package/src/hooks/README.md +8 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAddedKeys.ts +26 -0
- package/src/hooks/useFlip.ts +211 -0
- package/src/hooks/useIndicator.ts +135 -0
- package/src/hooks/useLingering.ts +122 -0
- package/src/hooks/usePresence.ts +100 -0
- package/src/hooks/useReducedMotion.ts +26 -0
- package/src/hooks/useTheme.ts +44 -4
- package/src/index.ts +1 -0
- package/src/lib/README.md +2 -0
- package/src/lib/cn.ts +3 -0
- package/src/lib/inline-style.ts +12 -0
- package/src/lib/motion.ts +90 -0
- package/src/styles/theme.css +81 -2
- package/src/styles/utilities.css +2 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ComponentProps } from "react";
|
|
2
|
+
import { useInView } from "../../hooks/useInView";
|
|
2
3
|
import { cn } from "../../lib/cn";
|
|
4
|
+
import { mergeRefs } from "../../lib/merge-refs";
|
|
3
5
|
|
|
4
6
|
export type SparklineVariant = "area" | "line";
|
|
5
7
|
export type SparklineTone = "info" | "danger" | "accent" | "neutral";
|
|
@@ -22,6 +24,8 @@ export type SparklineProps = Omit<ComponentProps<"svg">, "children"> & {
|
|
|
22
24
|
max?: number;
|
|
23
25
|
/** 传了它就是一张有名称的图;不传是纯装饰,对读屏隐藏 */
|
|
24
26
|
label?: string;
|
|
27
|
+
/** 进入视口时从左画出一次。默认开启 */
|
|
28
|
+
animate?: boolean;
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
/* 画在一个 100 × 100 的格子里,再由 SVG 拉伸到实际的盒子 */
|
|
@@ -58,7 +62,13 @@ export function sparklinePath(
|
|
|
58
62
|
return { line, area: `${line}L${SIZE} ${SIZE}L0 ${SIZE}Z` };
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
/*
|
|
65
|
+
/*
|
|
66
|
+
* data 与 alert 是不随主题变的角色色:白底和深色行带上都认得出。
|
|
67
|
+
* 高对比模式:SVG 上写的颜色系统不换,压在使用者自己定的页面色上可能看不清——
|
|
68
|
+
* 四种色调都换成页面的字色(色调说的事本来就要靠旁边的数字)
|
|
69
|
+
*/
|
|
70
|
+
const areaForced = "forced-colors:fill-[CanvasText]";
|
|
71
|
+
const lineForced = "forced-colors:stroke-[CanvasText]";
|
|
62
72
|
const areaClass: Record<SparklineTone, string> = {
|
|
63
73
|
info: "fill-data/75",
|
|
64
74
|
danger: "fill-alert/75",
|
|
@@ -84,10 +94,16 @@ export function Sparkline({
|
|
|
84
94
|
min,
|
|
85
95
|
max,
|
|
86
96
|
label,
|
|
97
|
+
animate = true,
|
|
87
98
|
className,
|
|
99
|
+
ref,
|
|
88
100
|
...props
|
|
89
101
|
}: SparklineProps) {
|
|
90
102
|
const path = sparklinePath(data, { min, max });
|
|
103
|
+
const [inViewRef, inView] = useInView<SVGSVGElement>({ disabled: !animate });
|
|
104
|
+
// 入场:整张图从左擦出来,面积和折线是同一个写法。一屏里的几张各看各的视口,同时到的一起画
|
|
105
|
+
const pending = animate && !inView;
|
|
106
|
+
const playing = animate && inView;
|
|
91
107
|
|
|
92
108
|
return (
|
|
93
109
|
<svg
|
|
@@ -100,12 +116,20 @@ export function Sparkline({
|
|
|
100
116
|
data-variant={variant}
|
|
101
117
|
data-tone={tone}
|
|
102
118
|
{...props}
|
|
103
|
-
|
|
104
|
-
className={cn(
|
|
119
|
+
ref={mergeRefs(inViewRef, ref)}
|
|
120
|
+
className={cn(
|
|
121
|
+
// 折线贴着上下边缘时,线宽的一半会落在盒子外面
|
|
122
|
+
"block h-8 w-full overflow-visible",
|
|
123
|
+
pending && "[clip-path:inset(0_100%_0_0)]",
|
|
124
|
+
// 擦的时候把盒子外面那一圈也留出来(线宽的一半,尖角还要多一点)
|
|
125
|
+
playing &&
|
|
126
|
+
"animate-wipe-in [animation-duration:var(--duration-slower)] [--wipe-bleed:4px]",
|
|
127
|
+
className,
|
|
128
|
+
)}
|
|
105
129
|
>
|
|
106
130
|
{path &&
|
|
107
131
|
(variant === "area" ? (
|
|
108
|
-
<path d={path.area} className={areaClass[tone]} />
|
|
132
|
+
<path d={path.area} className={cn(areaClass[tone], areaForced)} />
|
|
109
133
|
) : (
|
|
110
134
|
<path
|
|
111
135
|
d={path.line}
|
|
@@ -114,7 +138,7 @@ export function Sparkline({
|
|
|
114
138
|
strokeLinejoin="miter"
|
|
115
139
|
// 图被横向拉伸了,线宽不跟着变
|
|
116
140
|
vectorEffect="non-scaling-stroke"
|
|
117
|
-
className={lineClass[tone]}
|
|
141
|
+
className={cn(lineClass[tone], lineForced)}
|
|
118
142
|
/>
|
|
119
143
|
))}
|
|
120
144
|
</svg>
|
|
@@ -19,8 +19,12 @@ const sizeClass: Record<SpinnerSize, string> = {
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
// 系统开启"减少动态效果"时停成一个 45° 的菱形,和列表里的菱形记号是同一个样子
|
|
22
|
-
const square =
|
|
23
|
-
"animate-spin bg-current motion-reduce:rotate-45 motion-reduce:animate-none"
|
|
22
|
+
const square = [
|
|
23
|
+
"animate-spin bg-current motion-reduce:rotate-45 motion-reduce:animate-none",
|
|
24
|
+
// 高对比模式下底色被系统换成页面色,方块就没了:换成页面的字色。
|
|
25
|
+
// 写的是系统色,不是 currentColor——使用方用 className 给它另上了颜色时,这里照样看得见
|
|
26
|
+
"forced-colors:bg-[CanvasText]",
|
|
27
|
+
].join(" ");
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
30
|
* 行内加载指示:一个匀速旋转的小方块,颜色继承文字。
|
|
@@ -238,6 +238,8 @@ export function Stepper({
|
|
|
238
238
|
cn(
|
|
239
239
|
"flex shrink-0 items-center justify-center select-none",
|
|
240
240
|
"transition-colors duration-(--duration-fast) ease-standard",
|
|
241
|
+
// 高对比模式下底色都被系统换掉,加减钮只剩一个记号:补一圈线,和中间那一格连成三格
|
|
242
|
+
"forced-colors:border forced-colors:border-[ButtonText] forced-colors:disabled:border-[GrayText]",
|
|
241
243
|
buttonSize[size],
|
|
242
244
|
inactive
|
|
243
245
|
? "cursor-not-allowed bg-disabled text-on-disabled"
|
|
@@ -292,6 +294,8 @@ export function Stepper({
|
|
|
292
294
|
className={cn(
|
|
293
295
|
"min-w-0 flex-1 border-y-2 border-transparent bg-surface-sunken text-center font-tech font-bold tabular-nums outline-none",
|
|
294
296
|
"disabled:cursor-not-allowed",
|
|
297
|
+
// 高对比模式:上下各一条 1px 的线,和两端按钮的那一圈连成三格
|
|
298
|
+
"forced-colors:border-y forced-colors:border-[ButtonText] forced-colors:disabled:border-[GrayText]",
|
|
295
299
|
fieldSize[size],
|
|
296
300
|
disabled ? "text-ink-disabled" : "text-ink",
|
|
297
301
|
invalid && !disabled && "border-b-danger",
|
|
@@ -75,11 +75,16 @@ const railLayout: Record<StepsOrientation, string> = {
|
|
|
75
75
|
horizontal: "flex-col @md:flex-row",
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
-
/*
|
|
78
|
+
/*
|
|
79
|
+
* 线和节点之间留 8px,不从节点后面穿过。
|
|
80
|
+
* 高对比模式下线是底色画的,整条都会不见:没到的收成一条 1px 的细线,画成线自己的一条边
|
|
81
|
+
* (竖排在右、横排在下);走过的那一段照旧充进来,填上剩下的 1px,还是 2px。
|
|
82
|
+
* 走没走过是粗对细,不靠两种系统色分
|
|
83
|
+
*/
|
|
79
84
|
const lineLayout: Record<StepsOrientation, string> = {
|
|
80
|
-
vertical: "my-2 min-h-4 w-0.5",
|
|
85
|
+
vertical: "my-2 min-h-4 w-0.5 forced-colors:border-r",
|
|
81
86
|
horizontal:
|
|
82
|
-
"my-2 min-h-4 w-0.5 @md:mx-2 @md:my-0 @md:h-0.5 @md:min-h-0 @md:w-auto",
|
|
87
|
+
"my-2 min-h-4 w-0.5 @md:mx-2 @md:my-0 @md:h-0.5 @md:min-h-0 @md:w-auto forced-colors:@max-md:border-r forced-colors:@md:border-b",
|
|
83
88
|
};
|
|
84
89
|
|
|
85
90
|
const bodyLayout: Record<StepsOrientation, string> = {
|
|
@@ -161,11 +166,17 @@ const nodeSize: Record<StepsSize, string> = {
|
|
|
161
166
|
md: "size-6 text-xs [&_svg]:size-3.5",
|
|
162
167
|
};
|
|
163
168
|
|
|
169
|
+
/*
|
|
170
|
+
* 高对比模式下底色都被系统换掉:已完成的只剩一个勾浮着,当前的和未到的都成了空心框、
|
|
171
|
+
* 只差序号的粗细。三种状态还是三种形状——页面字色的实心块、系统"选中"色的实心块、空心框
|
|
172
|
+
*/
|
|
164
173
|
const nodeClass: Record<StepStatus, string> = {
|
|
165
174
|
// 墨底黄勾:和选中的复选框是同一个逻辑
|
|
166
|
-
done: "bg-surface-inverse text-accent-ink-inverse",
|
|
167
|
-
//
|
|
168
|
-
|
|
175
|
+
done: "bg-surface-inverse text-accent-ink-inverse forced-colors:bg-[CanvasText] forced-colors:text-[Canvas]",
|
|
176
|
+
// 描一圈最细的线:黄色对白底立不住。颜色跟着文字走,暗色下是近白。
|
|
177
|
+
// 序号是字,压在"选中"色上要关掉系统的接管(不然字后面会垫一块页面色),边线跟着自己写
|
|
178
|
+
current:
|
|
179
|
+
"border border-ink bg-action font-bold text-on-action forced-colors:forced-color-adjust-none forced-colors:border-[Highlight] forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]",
|
|
169
180
|
upcoming: "border border-line-strong text-ink-secondary",
|
|
170
181
|
};
|
|
171
182
|
|
|
@@ -243,6 +254,8 @@ export function Step({
|
|
|
243
254
|
<span
|
|
244
255
|
className={cn(
|
|
245
256
|
"flex shrink-0 items-center justify-center font-tech leading-none tabular-nums",
|
|
257
|
+
// 未到 → 当前 → 已完成,换色不硬切
|
|
258
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
246
259
|
nodeSize[size],
|
|
247
260
|
invalid ? "border border-danger text-danger" : nodeClass[status],
|
|
248
261
|
)}
|
|
@@ -255,12 +268,20 @@ export function Step({
|
|
|
255
268
|
String(index + 1).padStart(2, "0")
|
|
256
269
|
)}
|
|
257
270
|
</span>
|
|
258
|
-
{/*
|
|
271
|
+
{/*
|
|
272
|
+
这一步走完了,通向下一步的线才是实的。线自己一直是"没到"的颜色,
|
|
273
|
+
墨色画在 ::before 上,走完时从起点一端充到头,往回走时收回去。
|
|
274
|
+
从左上角两个方向一起放大,所以横排、竖排、容器变窄自动改竖排用的是同一组类。
|
|
275
|
+
墨色那一层比线多出去 16px、由线自己裁掉:这样粗的那个方向一开始就是满的
|
|
276
|
+
(不多出去的话,线是一边变长一边从 0 变粗的,半路上看着比别的线细)
|
|
277
|
+
*/}
|
|
259
278
|
<span
|
|
260
279
|
className={cn(
|
|
261
|
-
"flex-1 group-last/step:hidden",
|
|
280
|
+
"relative flex-1 overflow-clip bg-line-strong group-last/step:hidden",
|
|
281
|
+
"forced-colors:border-[CanvasText] forced-colors:before:bg-[CanvasText]",
|
|
282
|
+
"before:absolute before:top-0 before:-right-4 before:-bottom-4 before:left-0 before:origin-top-left before:bg-ink before:transition-[scale] before:duration-(--duration-base) before:ease-exit before:content-['']",
|
|
262
283
|
lineLayout[orientation],
|
|
263
|
-
status === "done" ? "
|
|
284
|
+
status === "done" ? "before:scale-100" : "before:scale-0",
|
|
264
285
|
)}
|
|
265
286
|
/>
|
|
266
287
|
</span>
|
|
@@ -22,6 +22,15 @@ const trackTransition =
|
|
|
22
22
|
"transition-colors duration-(--duration-fast) ease-standard";
|
|
23
23
|
const dualLabel =
|
|
24
24
|
"pointer-events-none relative min-w-9 px-3 text-center transition-colors duration-(--duration-fast) ease-standard peer-disabled:text-ink-disabled";
|
|
25
|
+
/*
|
|
26
|
+
* 高对比模式:旋钮是系统的"选中"色(禁用时是灰字色),压在它上面的那个值用配它的字色,
|
|
27
|
+
* 轨道上的另一个值用页面的字色。关的时候旋钮在"关"的值下面,开了换到"开"的值下面。
|
|
28
|
+
* 两个值的颜色都写明了,不让系统再改——不然它会在字后面垫一块页面色,把旋钮盖掉
|
|
29
|
+
*/
|
|
30
|
+
const dualOffForced =
|
|
31
|
+
"forced-colors:forced-color-adjust-none forced-colors:text-[HighlightText] forced-colors:peer-checked:text-[CanvasText] forced-colors:peer-disabled:text-[Canvas] forced-colors:peer-checked:peer-disabled:text-[GrayText]";
|
|
32
|
+
const dualOnForced =
|
|
33
|
+
"forced-colors:forced-color-adjust-none forced-colors:text-[CanvasText] forced-colors:peer-checked:text-[HighlightText] forced-colors:peer-disabled:text-[GrayText] forced-colors:peer-checked:peer-disabled:text-[Canvas]";
|
|
25
34
|
|
|
26
35
|
/**
|
|
27
36
|
* 开关:胶囊轨道 + 圆形旋钮,立即生效的设置用它(需要"保存"才生效的用复选框)。
|
|
@@ -58,6 +67,8 @@ export function Switch({
|
|
|
58
67
|
className={cn(
|
|
59
68
|
// 两个值都是有效值,轨道不随开关变色:反转块的 80%,压在图像上时略微透出底下
|
|
60
69
|
"peer absolute inset-0 size-full appearance-none rounded-full bg-surface-inverse/80 group-hover:bg-surface-inverse disabled:cursor-not-allowed disabled:bg-surface-muted",
|
|
70
|
+
// 高对比模式下底色都被系统换掉:轨道剩一圈边线,旋钮(下面那一块)用系统的"选中"色
|
|
71
|
+
"forced-colors:border forced-colors:border-[ButtonText] forced-colors:disabled:border-[GrayText]",
|
|
61
72
|
trackTransition,
|
|
62
73
|
focusRing,
|
|
63
74
|
)}
|
|
@@ -68,20 +79,29 @@ export function Switch({
|
|
|
68
79
|
"pointer-events-none absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-surface-muted",
|
|
69
80
|
"transition-[translate] duration-(--duration-fast) ease-standard",
|
|
70
81
|
"peer-checked:translate-x-full peer-disabled:bg-surface-raised",
|
|
82
|
+
"forced-colors:bg-[Highlight] forced-colors:peer-disabled:bg-[GrayText]",
|
|
71
83
|
)}
|
|
72
84
|
/>
|
|
73
85
|
{/* 旋钮上的值用墨色,轨道上的另一个值降到 70% */}
|
|
74
86
|
<span
|
|
75
87
|
aria-hidden="true"
|
|
76
88
|
data-value="off"
|
|
77
|
-
className={cn(
|
|
89
|
+
className={cn(
|
|
90
|
+
dualLabel,
|
|
91
|
+
"text-ink peer-checked:text-ink-inverse/70",
|
|
92
|
+
dualOffForced,
|
|
93
|
+
)}
|
|
78
94
|
>
|
|
79
95
|
{offLabel}
|
|
80
96
|
</span>
|
|
81
97
|
<span
|
|
82
98
|
aria-hidden="true"
|
|
83
99
|
data-value="on"
|
|
84
|
-
className={cn(
|
|
100
|
+
className={cn(
|
|
101
|
+
dualLabel,
|
|
102
|
+
"text-ink-inverse/70 peer-checked:text-ink",
|
|
103
|
+
dualOnForced,
|
|
104
|
+
)}
|
|
85
105
|
>
|
|
86
106
|
{onLabel}
|
|
87
107
|
</span>
|
|
@@ -96,6 +116,9 @@ export function Switch({
|
|
|
96
116
|
className={cn(
|
|
97
117
|
// 开:反转块作轨道。颜色只点在旋钮上,不是整条黄色轨道
|
|
98
118
|
"peer h-7 w-13 appearance-none rounded-full bg-line-strong group-hover:bg-ink-tertiary checked:bg-surface-inverse disabled:cursor-not-allowed disabled:bg-surface-muted",
|
|
119
|
+
// 高对比模式下底色都被系统换掉,整个开关就没了:关是一圈边线,开是系统"选中"色的实心轨道
|
|
120
|
+
"forced-colors:border-2 forced-colors:border-[ButtonText] forced-colors:checked:border-[Highlight] forced-colors:checked:bg-[Highlight]",
|
|
121
|
+
"forced-colors:disabled:border-[GrayText] forced-colors:checked:disabled:bg-[GrayText]",
|
|
99
122
|
trackTransition,
|
|
100
123
|
focusRing,
|
|
101
124
|
)}
|
|
@@ -106,6 +129,7 @@ export function Switch({
|
|
|
106
129
|
"pointer-events-none absolute top-1 left-1 size-5 rounded-full bg-surface-raised",
|
|
107
130
|
"transition-[translate,background-color] duration-(--duration-fast) ease-standard",
|
|
108
131
|
"peer-checked:translate-x-6 peer-checked:bg-accent-ink-inverse peer-disabled:bg-ink-disabled",
|
|
132
|
+
"forced-colors:bg-[ButtonText] forced-colors:peer-checked:bg-[HighlightText] forced-colors:peer-disabled:bg-[GrayText] forced-colors:peer-checked:peer-disabled:bg-[Canvas]",
|
|
109
133
|
)}
|
|
110
134
|
/>
|
|
111
135
|
</span>
|
|
@@ -16,10 +16,12 @@ import {
|
|
|
16
16
|
} from "react";
|
|
17
17
|
import { useControllableState } from "../../hooks/useControllableState";
|
|
18
18
|
import { useOverflowing } from "../../hooks/useOverflowing";
|
|
19
|
+
import { usePresence, type Presence } from "../../hooks/usePresence";
|
|
19
20
|
import { TriangleRight } from "../../icons/TriangleRight";
|
|
20
21
|
import { cn } from "../../lib/cn";
|
|
21
22
|
import { focusRing, focusRingInset } from "../../lib/focus-ring";
|
|
22
23
|
import { mergeRefs } from "../../lib/merge-refs";
|
|
24
|
+
import { collapse, collapseEnter, collapseInner } from "../../lib/motion";
|
|
23
25
|
|
|
24
26
|
export type TableHeaderVariant = "band" | "muted";
|
|
25
27
|
export type TableSize = "sm" | "md";
|
|
@@ -173,10 +175,11 @@ export type TableBodyProps = ComponentProps<"tbody">;
|
|
|
173
175
|
|
|
174
176
|
/*
|
|
175
177
|
* 每隔五行加重一条线。只数主行:展开的明细也是一个 <tr>,不能让它把数数错。
|
|
176
|
-
*
|
|
178
|
+
* 第五行后面跟着明细的时候(展开着,或者正在收起),它的下边线没有了,
|
|
179
|
+
* 加重的那条画在它的明细下面
|
|
177
180
|
*/
|
|
178
181
|
const ruledLines = [
|
|
179
|
-
"[&>tr:nth-child(5n_of_:not([data-detail])):not([data-
|
|
182
|
+
"[&>tr:nth-child(5n_of_:not([data-detail])):not(:has(+tr[data-detail]))>*]:border-line-strong",
|
|
180
183
|
"[&>tr:nth-child(5n_of_:not([data-detail]))+tr[data-detail]>*]:border-line-strong",
|
|
181
184
|
].join(" ");
|
|
182
185
|
|
|
@@ -241,6 +244,8 @@ export function TableRow({
|
|
|
241
244
|
const expandable =
|
|
242
245
|
detail !== undefined && detail !== null && detail !== false;
|
|
243
246
|
const open = expandable && expanded;
|
|
247
|
+
// 收起:明细先按高度收回去,收完再卸载
|
|
248
|
+
const detailPresence = usePresence<HTMLDivElement>(open);
|
|
244
249
|
|
|
245
250
|
const context = useMemo(
|
|
246
251
|
() =>
|
|
@@ -269,18 +274,19 @@ export function TableRow({
|
|
|
269
274
|
className={cn(
|
|
270
275
|
"group/row transition-colors duration-(--duration-fast) ease-standard",
|
|
271
276
|
selected ? rowSelected : rowIdle,
|
|
272
|
-
//
|
|
273
|
-
"[
|
|
277
|
+
// 后面跟着明细(展开着,或者正在收起):它们是一块,中间不画线
|
|
278
|
+
"[&:has(+tr[data-detail])>*]:border-b-transparent",
|
|
274
279
|
className,
|
|
275
280
|
)}
|
|
276
281
|
>
|
|
277
282
|
<RowContext value={context}>{children}</RowContext>
|
|
278
283
|
</tr>
|
|
279
|
-
{
|
|
284
|
+
{detailPresence.mounted && (
|
|
280
285
|
<DetailRow
|
|
281
286
|
id={detailId}
|
|
282
287
|
rowRef={rowRef}
|
|
283
288
|
estimate={countCells(children)}
|
|
289
|
+
presence={detailPresence}
|
|
284
290
|
>
|
|
285
291
|
{detail}
|
|
286
292
|
</DetailRow>
|
|
@@ -303,11 +309,13 @@ function DetailRow({
|
|
|
303
309
|
id,
|
|
304
310
|
rowRef,
|
|
305
311
|
estimate,
|
|
312
|
+
presence,
|
|
306
313
|
children,
|
|
307
314
|
}: {
|
|
308
315
|
id: string;
|
|
309
316
|
rowRef: RefObject<HTMLTableRowElement | null>;
|
|
310
317
|
estimate: number;
|
|
318
|
+
presence: Presence<HTMLDivElement>;
|
|
311
319
|
children: ReactNode;
|
|
312
320
|
}) {
|
|
313
321
|
const [measured, setMeasured] = useState<number | null>(null);
|
|
@@ -323,14 +331,30 @@ function DetailRow({
|
|
|
323
331
|
});
|
|
324
332
|
|
|
325
333
|
return (
|
|
326
|
-
|
|
334
|
+
// 正在收起的明细点不到、Tab 不进去
|
|
335
|
+
<tr id={id} data-detail="" inert={presence.leaving}>
|
|
327
336
|
<td
|
|
328
337
|
colSpan={measured ?? estimate}
|
|
329
338
|
className="border-b border-line bg-surface-sunken p-0 [tr:last-child>&]:border-b-0"
|
|
330
339
|
>
|
|
331
|
-
{/*
|
|
332
|
-
|
|
333
|
-
|
|
340
|
+
{/*
|
|
341
|
+
* 表格横向滚动时明细不跟着滚走:钉在容器的左缘,宽度是容器看得见的那一段。
|
|
342
|
+
* 收放也在这一层上——表格行的高度过渡不了,过渡的是单元格里的它
|
|
343
|
+
*/}
|
|
344
|
+
<div
|
|
345
|
+
ref={presence.ref}
|
|
346
|
+
data-leaving={presence.leaving ? "" : undefined}
|
|
347
|
+
className={cn(
|
|
348
|
+
"sticky left-0 w-[var(--table-viewport,100%)]",
|
|
349
|
+
collapse,
|
|
350
|
+
presence.entered && collapseEnter,
|
|
351
|
+
)}
|
|
352
|
+
>
|
|
353
|
+
<div className={collapseInner}>
|
|
354
|
+
<div className="px-4 py-3 text-sm whitespace-normal">
|
|
355
|
+
{children}
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
334
358
|
</div>
|
|
335
359
|
</td>
|
|
336
360
|
</tr>
|
|
@@ -3,14 +3,18 @@ import {
|
|
|
3
3
|
useContext,
|
|
4
4
|
useId,
|
|
5
5
|
useMemo,
|
|
6
|
+
useState,
|
|
6
7
|
type ComponentProps,
|
|
7
8
|
type KeyboardEvent,
|
|
8
9
|
type MouseEvent,
|
|
9
10
|
} from "react";
|
|
10
11
|
import { useControllableState } from "../../hooks/useControllableState";
|
|
12
|
+
import { useIndicator } from "../../hooks/useIndicator";
|
|
11
13
|
import { TriangleRight } from "../../icons/TriangleRight";
|
|
12
14
|
import { cn } from "../../lib/cn";
|
|
13
15
|
import { focusRing, focusRingInset } from "../../lib/focus-ring";
|
|
16
|
+
import { mergeRefs } from "../../lib/merge-refs";
|
|
17
|
+
import { fadeInFast, indicator, indicatorBox } from "../../lib/motion";
|
|
14
18
|
import { capsuleBase, capsuleSize, capsuleState } from "../chip/capsule-style";
|
|
15
19
|
|
|
16
20
|
export type TabsVariant = "block" | "capsule" | "wedge";
|
|
@@ -22,6 +26,8 @@ type TabsContextValue = {
|
|
|
22
26
|
baseId: string;
|
|
23
27
|
variant: TabsVariant;
|
|
24
28
|
size: TabsSize;
|
|
29
|
+
/** 切换过没有:一开始选中的那一块面板是静止的,切过来的才淡入 */
|
|
30
|
+
switched: boolean;
|
|
25
31
|
};
|
|
26
32
|
|
|
27
33
|
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
@@ -71,9 +77,13 @@ export function Tabs({
|
|
|
71
77
|
onChange: onValueChange,
|
|
72
78
|
});
|
|
73
79
|
|
|
80
|
+
const [initial] = useState(current);
|
|
81
|
+
const [switched, setSwitched] = useState(false);
|
|
82
|
+
if (!switched && current !== initial) setSwitched(true);
|
|
83
|
+
|
|
74
84
|
const context = useMemo(
|
|
75
|
-
() => ({ value: current, select, baseId, variant, size }),
|
|
76
|
-
[current, select, baseId, variant, size],
|
|
85
|
+
() => ({ value: current, select, baseId, variant, size, switched }),
|
|
86
|
+
[current, select, baseId, variant, size, switched],
|
|
77
87
|
);
|
|
78
88
|
|
|
79
89
|
return (
|
|
@@ -85,8 +95,12 @@ export function Tabs({
|
|
|
85
95
|
|
|
86
96
|
export type TabListProps = ComponentProps<"div">;
|
|
87
97
|
|
|
88
|
-
export function TabList({ className, onKeyDown, ...props }: TabListProps) {
|
|
98
|
+
export function TabList({ className, onKeyDown, ref, ...props }: TabListProps) {
|
|
89
99
|
const { variant } = useTabsContext("TabList");
|
|
100
|
+
// 胶囊:选中的底画在这一栏上,换一个时是同一块滑过去。另外两种的"让位"是实测的,不动
|
|
101
|
+
const listRef = useIndicator<HTMLDivElement>(
|
|
102
|
+
variant === "capsule" ? '[role="tab"][aria-selected="true"]' : null,
|
|
103
|
+
);
|
|
90
104
|
|
|
91
105
|
// 漫游 tabindex:方向键在页签之间移动焦点并直接切换
|
|
92
106
|
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
@@ -128,13 +142,24 @@ export function TabList({ className, onKeyDown, ...props }: TabListProps) {
|
|
|
128
142
|
return (
|
|
129
143
|
<div
|
|
130
144
|
{...props}
|
|
145
|
+
ref={mergeRefs(listRef, ref)}
|
|
131
146
|
role="tablist"
|
|
132
147
|
aria-orientation="horizontal"
|
|
133
148
|
onKeyDown={handleKeyDown}
|
|
134
149
|
className={cn(
|
|
135
150
|
// 页签很多时横向滚动,不换行
|
|
136
151
|
"flex overflow-x-auto [scrollbar-width:thin]",
|
|
137
|
-
variant === "capsule" &&
|
|
152
|
+
variant === "capsule" && [
|
|
153
|
+
"gap-2 p-1",
|
|
154
|
+
// 胶囊各有各的宽:底滑过去的时候宽度跟着变。它在滚动的内容里,跟着内容走
|
|
155
|
+
indicator,
|
|
156
|
+
indicatorBox,
|
|
157
|
+
// 墨底提到各个胶囊的轮廓线之上:滑过去的时候是一块干净的实心胶囊,
|
|
158
|
+
// 不被相邻胶囊的轮廓切开。胶囊里的字另提一层,留在它上面
|
|
159
|
+
"after:z-0 after:rounded-full after:bg-surface-inverse",
|
|
160
|
+
// 高对比模式下底色被系统换掉,这一块成了盖住选中胶囊轮廓的一片页面色:改用系统的"选中"色
|
|
161
|
+
"forced-colors:after:bg-[Highlight]",
|
|
162
|
+
],
|
|
138
163
|
// 楔形坐在一条墨线上:亮色页面上黄色对白底的明度差很小,靠这条线托住
|
|
139
164
|
variant === "wedge" && "border-b-2 border-ink",
|
|
140
165
|
className,
|
|
@@ -205,14 +230,21 @@ export function Tab({
|
|
|
205
230
|
focusRing,
|
|
206
231
|
capsuleSize[size],
|
|
207
232
|
selected
|
|
208
|
-
?
|
|
233
|
+
? // 这一栏量到了位置,底就由栏上滑动的那一块来画
|
|
234
|
+
[
|
|
235
|
+
capsuleState.selected,
|
|
236
|
+
"in-data-indicator:bg-transparent forced-colors:in-data-indicator:bg-transparent",
|
|
237
|
+
]
|
|
209
238
|
: disabled
|
|
210
239
|
? capsuleState.disabled
|
|
211
240
|
: capsuleState.rest,
|
|
212
241
|
className,
|
|
213
242
|
)}
|
|
214
243
|
>
|
|
215
|
-
{
|
|
244
|
+
{/* 字在滑动的墨底之上;间距跟按钮自己的走,里面照旧是一行居中的弹性排布 */}
|
|
245
|
+
<span className="relative z-1 inline-flex items-center gap-[inherit]">
|
|
246
|
+
{children}
|
|
247
|
+
</span>
|
|
216
248
|
</button>
|
|
217
249
|
);
|
|
218
250
|
}
|
|
@@ -229,7 +261,13 @@ export function Tab({
|
|
|
229
261
|
focusRingInset,
|
|
230
262
|
wedgeSize[size],
|
|
231
263
|
selected
|
|
232
|
-
?
|
|
264
|
+
? // 焦点环画在里面、压在黄楔上:普通的焦点色在暗色主题下也是黄的,看不见,
|
|
265
|
+
// 换成压得住黄底的颜色。再往里收 2px:这个颜色贴着边的话,在暗色页面上
|
|
266
|
+
// 和页面分不开,看上去只是黄楔小了一圈。
|
|
267
|
+
// 高对比模式下黄底被系统换掉:楔形改用系统的"选中"色,字用配它的字色;
|
|
268
|
+
// 这一个的颜色不让系统再改,不然它会在字后面垫一块页面色。
|
|
269
|
+
// 画在里面的焦点环也用那个字色,再往里收 2px:贴着边的话它和页面一个颜色
|
|
270
|
+
"text-on-action before:bg-action focus-visible:-outline-offset-4 focus-visible:outline-on-action forced-colors:forced-color-adjust-none forced-colors:text-[HighlightText] forced-colors:before:bg-[Highlight] forced-colors:focus-visible:-outline-offset-4 forced-colors:focus-visible:outline-[HighlightText]"
|
|
233
271
|
: disabled
|
|
234
272
|
? "cursor-not-allowed text-ink-disabled"
|
|
235
273
|
: "text-ink hover:before:bg-ink/5",
|
|
@@ -287,7 +325,10 @@ export type TabPanelProps = ComponentProps<"div"> & {
|
|
|
287
325
|
value: string;
|
|
288
326
|
};
|
|
289
327
|
|
|
290
|
-
/**
|
|
328
|
+
/**
|
|
329
|
+
* 未选中的面板保留一个隐藏的空容器,让页签的 `aria-controls` 始终有所指。
|
|
330
|
+
* 切过来的面板淡入;一开始就选中的那一块不动。
|
|
331
|
+
*/
|
|
291
332
|
export function TabPanel({
|
|
292
333
|
value,
|
|
293
334
|
className,
|
|
@@ -305,7 +346,12 @@ export function TabPanel({
|
|
|
305
346
|
aria-labelledby={toId(context.baseId, "tab", value)}
|
|
306
347
|
hidden={!selected}
|
|
307
348
|
tabIndex={0}
|
|
308
|
-
className={cn(
|
|
349
|
+
className={cn(
|
|
350
|
+
focusRing,
|
|
351
|
+
// 只淡入、不位移:页签本身已经有"让位"的动作
|
|
352
|
+
selected && context.switched && fadeInFast,
|
|
353
|
+
className,
|
|
354
|
+
)}
|
|
309
355
|
>
|
|
310
356
|
{selected ? children : null}
|
|
311
357
|
</div>
|