@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
|
@@ -85,6 +85,7 @@ export function NavMenu({
|
|
|
85
85
|
initialFocus={firstFocusableIn(bodyRef)}
|
|
86
86
|
className={cn(
|
|
87
87
|
"fixed inset-0 z-(--z-overlay) flex flex-col overflow-hidden bg-surface text-ink outline-none",
|
|
88
|
+
// 整层淡入;里面的栏目另有自己的入场(见 itemEnter)
|
|
88
89
|
"transition-opacity duration-(--duration-fast) ease-standard",
|
|
89
90
|
"data-ending-style:opacity-0 data-starting-style:opacity-0",
|
|
90
91
|
className,
|
|
@@ -155,6 +156,17 @@ export function NavMenu({
|
|
|
155
156
|
);
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
/*
|
|
160
|
+
* 栏目入场:逐条从左滑入 8px。第一项等层淡入过半(100ms),之后每项晚 50ms,
|
|
161
|
+
* 第六项起一起到——从第一项动到最后一项停下 550ms。
|
|
162
|
+
* 只动 <li> 的位置和透明度:焦点一打开就在第一项上,键盘不用等它
|
|
163
|
+
*/
|
|
164
|
+
const itemEnter = [
|
|
165
|
+
"animate-shift-in [--shift-x:calc(var(--motion-shift-lg)*-1)]",
|
|
166
|
+
"[animation-delay:100ms] nth-2:[animation-delay:150ms] nth-3:[animation-delay:200ms]",
|
|
167
|
+
"nth-4:[animation-delay:250ms] nth-5:[animation-delay:300ms] nth-[n+6]:[animation-delay:350ms]",
|
|
168
|
+
].join(" ");
|
|
169
|
+
|
|
158
170
|
type ItemOwnProps = {
|
|
159
171
|
/** 图标,后面跟一条竖向的分隔线 */
|
|
160
172
|
icon?: ReactNode;
|
|
@@ -188,6 +200,7 @@ export function NavMenuItem({
|
|
|
188
200
|
rel,
|
|
189
201
|
render,
|
|
190
202
|
onClick,
|
|
203
|
+
className,
|
|
191
204
|
children,
|
|
192
205
|
...props
|
|
193
206
|
}: NavMenuItemProps) {
|
|
@@ -213,8 +226,9 @@ export function NavMenuItem({
|
|
|
213
226
|
// 黄和旁边的浅灰明度很接近:另加粗体和左缘一条墨色竖条,不只靠颜色
|
|
214
227
|
"bg-action font-bold text-on-action",
|
|
215
228
|
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-on-action before:content-['']",
|
|
216
|
-
//
|
|
217
|
-
|
|
229
|
+
// 普通的焦点色在暗色主题下也是黄的,压在黄底上看不见。换成墨色之后再往里收 2px:
|
|
230
|
+
// 贴着边的话它在暗色页面上和页面分不开,看上去只是黄条小了一圈
|
|
231
|
+
"focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-on-action",
|
|
218
232
|
]
|
|
219
233
|
: [
|
|
220
234
|
"bg-surface-sunken text-ink hover:bg-surface-muted active:bg-line",
|
|
@@ -256,7 +270,7 @@ export function NavMenuItem({
|
|
|
256
270
|
);
|
|
257
271
|
|
|
258
272
|
return (
|
|
259
|
-
<li {...props}>
|
|
273
|
+
<li {...props} className={cn(itemEnter, className)}>
|
|
260
274
|
{isLink ? (
|
|
261
275
|
<LinkElement
|
|
262
276
|
render={render}
|
|
@@ -3,7 +3,11 @@ import { Fragment, useId, type ComponentProps } from "react";
|
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
import { focusRing } from "../../lib/focus-ring";
|
|
5
5
|
import { useFieldContext, useFieldControl } from "../field/Field";
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
readOnlyFrame,
|
|
8
|
+
type ControlSize,
|
|
9
|
+
type ControlVariant,
|
|
10
|
+
} from "../input/control-box";
|
|
7
11
|
|
|
8
12
|
export type OtpInputType = "numeric" | "alphanumeric";
|
|
9
13
|
export type OtpInputSize = ControlSize;
|
|
@@ -129,8 +133,11 @@ export function OtpInput({
|
|
|
129
133
|
const groupLabel = ownLabel ?? fieldContext?.labelId;
|
|
130
134
|
|
|
131
135
|
const slotClass = cn(
|
|
132
|
-
|
|
136
|
+
// 字色:空格是透明的,填上(基元给的 data-filled)才有颜色。下面那条颜色的过渡
|
|
137
|
+
// 本来是给边线的,字色跟着它走——所以填进去的那一位是淡入的。光标另有自己的颜色
|
|
138
|
+
"min-w-0 shrink text-center font-tech font-medium tabular-nums text-transparent caret-ink",
|
|
133
139
|
"transition-colors duration-(--duration-fast) ease-standard",
|
|
140
|
+
disabled ? "data-filled:text-ink-disabled" : "data-filled:text-ink",
|
|
134
141
|
focusRing,
|
|
135
142
|
slotSize[size],
|
|
136
143
|
slotFrame[variant],
|
|
@@ -138,8 +145,9 @@ export function OtpInput({
|
|
|
138
145
|
? // 只读:没有边线、底变透明。边线留透明的,尺寸不跳
|
|
139
146
|
"border-transparent bg-transparent"
|
|
140
147
|
: slotFill[variant],
|
|
148
|
+
readOnly && !disabled && !invalid && readOnlyFrame,
|
|
141
149
|
disabled
|
|
142
|
-
? "cursor-not-allowed border-line
|
|
150
|
+
? "cursor-not-allowed border-line"
|
|
143
151
|
: invalid
|
|
144
152
|
? "border-danger"
|
|
145
153
|
: !readOnly &&
|
|
@@ -191,7 +199,8 @@ export function OtpInput({
|
|
|
191
199
|
<span
|
|
192
200
|
aria-hidden="true"
|
|
193
201
|
data-separator=""
|
|
194
|
-
|
|
202
|
+
// 高对比模式下底色被系统换掉,短横就没了,只剩一段比别处宽的空:换成页面的字色
|
|
203
|
+
className="h-0.5 w-2 shrink-0 bg-line-strong forced-colors:bg-[CanvasText]"
|
|
195
204
|
/>
|
|
196
205
|
)}
|
|
197
206
|
<BaseOtpField.Input
|
|
@@ -26,10 +26,23 @@ export type PageHeaderProps = Omit<ComponentProps<"header">, "title"> & {
|
|
|
26
26
|
* 上面还有吸顶的顶栏时用 `className` 改 `top`
|
|
27
27
|
*/
|
|
28
28
|
sticky?: boolean;
|
|
29
|
+
/** 挂上时播放一次入场:微文字行 → 标题 → 说明。默认开启;`sticky` 时不播 */
|
|
30
|
+
animate?: boolean;
|
|
29
31
|
/** 标题下面的一行:页签、几个统计块 */
|
|
30
32
|
children?: ReactNode;
|
|
31
33
|
};
|
|
32
34
|
|
|
35
|
+
/*
|
|
36
|
+
* 入场的三拍:各自从左 8px 淡入归位,依次晚 100ms。
|
|
37
|
+
* 挂上就播(纯 CSS):页头在页面最上面,不用等它进视口
|
|
38
|
+
*/
|
|
39
|
+
const enter = "animate-shift-in [--shift-x:calc(var(--motion-shift-lg)*-1)]";
|
|
40
|
+
const enterStep = [
|
|
41
|
+
enter,
|
|
42
|
+
`${enter} [animation-delay:100ms]`,
|
|
43
|
+
`${enter} [animation-delay:200ms]`,
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
33
46
|
/**
|
|
34
47
|
* 页头:这是哪一页、怎么回去、在这一页能做什么。一页一个。
|
|
35
48
|
* 按自身的宽度响应:行动区放不下时折到标题下面。
|
|
@@ -44,11 +57,15 @@ export function PageHeader({
|
|
|
44
57
|
actions,
|
|
45
58
|
divider = false,
|
|
46
59
|
sticky = false,
|
|
60
|
+
animate = true,
|
|
47
61
|
className,
|
|
48
62
|
children,
|
|
49
63
|
...props
|
|
50
64
|
}: PageHeaderProps) {
|
|
51
65
|
const Heading = `h${level}` as const;
|
|
66
|
+
// 入场只动这三行字:面包屑、返回、行动区是能点的,位置在动的东西不好点
|
|
67
|
+
const reveal = animate && !sticky;
|
|
68
|
+
const first = meta ? 1 : 0;
|
|
52
69
|
|
|
53
70
|
return (
|
|
54
71
|
<header
|
|
@@ -64,7 +81,16 @@ export function PageHeader({
|
|
|
64
81
|
>
|
|
65
82
|
{breadcrumb}
|
|
66
83
|
<div className="flex flex-col gap-1">
|
|
67
|
-
{meta &&
|
|
84
|
+
{meta && (
|
|
85
|
+
<p
|
|
86
|
+
className={cn(
|
|
87
|
+
"font-tech text-xs text-ink-secondary",
|
|
88
|
+
reveal && enterStep[0],
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
{meta}
|
|
92
|
+
</p>
|
|
93
|
+
)}
|
|
68
94
|
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-4">
|
|
69
95
|
{/* 标题这一组至少留 16rem:行动区挤不进来就整块折下去,而不是把标题挤成一个字一行 */}
|
|
70
96
|
<div className="flex min-w-0 flex-1 basis-64 items-start gap-4">
|
|
@@ -78,11 +104,21 @@ export function PageHeader({
|
|
|
78
104
|
</div>
|
|
79
105
|
)}
|
|
80
106
|
<div className="flex min-w-0 flex-col gap-1">
|
|
81
|
-
<Heading
|
|
107
|
+
<Heading
|
|
108
|
+
className={cn(
|
|
109
|
+
"min-w-0 text-2xl leading-10 font-bold wrap-anywhere @md:text-3xl @md:leading-12",
|
|
110
|
+
reveal && enterStep[first],
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
82
113
|
{title}
|
|
83
114
|
</Heading>
|
|
84
115
|
{description && (
|
|
85
|
-
<p
|
|
116
|
+
<p
|
|
117
|
+
className={cn(
|
|
118
|
+
"max-w-prose text-sm text-ink-secondary",
|
|
119
|
+
reveal && enterStep[first + 1],
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
86
122
|
{description}
|
|
87
123
|
</p>
|
|
88
124
|
)}
|
|
@@ -116,6 +116,8 @@ export function Pagination({
|
|
|
116
116
|
data-size={size}
|
|
117
117
|
className={cn(
|
|
118
118
|
"@container hatch flex items-center justify-between gap-2 bg-surface-muted px-1 text-ink",
|
|
119
|
+
// 高对比模式下条底和斜纹都不画,看不出这是一整条:补一圈线,画在内边距里,不占地方
|
|
120
|
+
"forced-colors:outline forced-colors:-outline-offset-1 forced-colors:outline-[ButtonText]",
|
|
119
121
|
barSize[size],
|
|
120
122
|
className,
|
|
121
123
|
)}
|
|
@@ -29,7 +29,14 @@ const trackSize: Record<ProgressSize, string> = {
|
|
|
29
29
|
* 填充是纯色,不描边:亮色界面上一圈墨色的框显得重。
|
|
30
30
|
* 亮色下它对浅灰轨道只有 1.3:1,确切数值靠 showValue 或旁边的文字给出。
|
|
31
31
|
*/
|
|
32
|
-
const fillClass = "bg-action";
|
|
32
|
+
const fillClass = "bg-action forced-colors:bg-[Highlight]";
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* 高对比模式下底色都被系统换掉,轨道和填充就一起没了:轨道剩一圈 1px 的线(空心),
|
|
36
|
+
* 填充是系统"选中"色的实心条——空心对实心,不靠两种系统色分得开
|
|
37
|
+
*/
|
|
38
|
+
const trackClass =
|
|
39
|
+
"bg-line forced-colors:outline forced-colors:-outline-offset-1 forced-colors:outline-[CanvasText]";
|
|
33
40
|
|
|
34
41
|
/**
|
|
35
42
|
* 进度:灰轨 + 黄填充。表示"完成了多少";表示"数量有多少"用图表,那是蓝色的事。
|
|
@@ -63,7 +70,7 @@ export function Progress({
|
|
|
63
70
|
<span
|
|
64
71
|
key={index}
|
|
65
72
|
data-done={index < done ? "" : undefined}
|
|
66
|
-
className={cn("flex-1", index < done ? fillClass :
|
|
73
|
+
className={cn("flex-1", index < done ? fillClass : trackClass)}
|
|
67
74
|
/>
|
|
68
75
|
))}
|
|
69
76
|
</span>
|
|
@@ -117,7 +124,7 @@ export function Progress({
|
|
|
117
124
|
className={cn(
|
|
118
125
|
"relative min-w-0 flex-1",
|
|
119
126
|
trackSize[size],
|
|
120
|
-
!segmented &&
|
|
127
|
+
!segmented && trackClass,
|
|
121
128
|
)}
|
|
122
129
|
>
|
|
123
130
|
{bar}
|
|
@@ -16,6 +16,15 @@ export type ProgressRingProps = Omit<ComponentProps<"div">, "children"> & {
|
|
|
16
16
|
children?: ReactNode;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
/*
|
|
20
|
+
* 高对比模式:SVG 上写的颜色系统不换,轨道和弧还是主题里的浅灰和黄,
|
|
21
|
+
* 压在使用者自己定的页面色上可能看不清。轨道收成一圈 1px 的细线,
|
|
22
|
+
* 弧是系统"选中"色、照旧是原来的线宽——细对粗,不靠两种系统色分得开
|
|
23
|
+
*/
|
|
24
|
+
const trackClass =
|
|
25
|
+
"stroke-line forced-colors:stroke-[CanvasText] forced-colors:stroke-1";
|
|
26
|
+
const arcClass = "stroke-action forced-colors:stroke-[Highlight]";
|
|
27
|
+
|
|
19
28
|
/**
|
|
20
29
|
* 进度环:细圆环轨道 + 从 12 点方向顺时针的黄弧。用在紧凑的位置,或者围住一个头像、图标。
|
|
21
30
|
* 取色与 `Progress` 相同;需要名称时传 `aria-label`。
|
|
@@ -53,13 +62,16 @@ export function ProgressRing({
|
|
|
53
62
|
<>
|
|
54
63
|
<circle
|
|
55
64
|
{...circle}
|
|
56
|
-
className=
|
|
65
|
+
className={cn(
|
|
66
|
+
"origin-center animate-spin motion-reduce:hidden",
|
|
67
|
+
arcClass,
|
|
68
|
+
)}
|
|
57
69
|
strokeDasharray={`${circumference / 4} ${circumference}`}
|
|
58
70
|
/>
|
|
59
71
|
{/* 减少动态效果时换成一圈虚线:静止的一段弧会被看成"进度 25%" */}
|
|
60
72
|
<circle
|
|
61
73
|
{...circle}
|
|
62
|
-
className="hidden
|
|
74
|
+
className={cn("hidden motion-reduce:block", arcClass)}
|
|
63
75
|
strokeDasharray={`${stroke} ${stroke * 2}`}
|
|
64
76
|
/>
|
|
65
77
|
</>
|
|
@@ -68,7 +80,10 @@ export function ProgressRing({
|
|
|
68
80
|
arc = (
|
|
69
81
|
<circle
|
|
70
82
|
{...circle}
|
|
71
|
-
className=
|
|
83
|
+
className={cn(
|
|
84
|
+
"transition-[stroke-dashoffset] duration-(--duration-base) ease-standard",
|
|
85
|
+
arcClass,
|
|
86
|
+
)}
|
|
72
87
|
strokeDasharray={circumference}
|
|
73
88
|
strokeDashoffset={circumference * (1 - ratio)}
|
|
74
89
|
/>
|
|
@@ -114,7 +129,7 @@ export function ProgressRing({
|
|
|
114
129
|
aria-hidden="true"
|
|
115
130
|
className="absolute inset-0 -rotate-90"
|
|
116
131
|
>
|
|
117
|
-
<circle {...circle} className=
|
|
132
|
+
<circle {...circle} className={trackClass} />
|
|
118
133
|
{arc}
|
|
119
134
|
</svg>
|
|
120
135
|
{center}
|
|
@@ -172,6 +172,8 @@ export function Radio({
|
|
|
172
172
|
className={cn(
|
|
173
173
|
choiceMark,
|
|
174
174
|
"size-2 rounded-full bg-accent-ink-inverse peer-checked:opacity-100 peer-disabled:bg-ink-disabled choice-diamond:hidden",
|
|
175
|
+
// 高对比模式:圆点也是底色画的,换成"选中"色上面的字色
|
|
176
|
+
"forced-colors:bg-[HighlightText] forced-colors:peer-disabled:bg-[GrayText]",
|
|
175
177
|
)}
|
|
176
178
|
/>
|
|
177
179
|
</span>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useState,
|
|
4
|
+
useSyncExternalStore,
|
|
5
|
+
type ComponentProps,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { useInView } from "../../hooks/useInView";
|
|
8
|
+
import { useReducedMotion } from "../../hooks/useReducedMotion";
|
|
9
|
+
import { cn } from "../../lib/cn";
|
|
10
|
+
import { mergeRefs } from "../../lib/merge-refs";
|
|
11
|
+
|
|
12
|
+
export type RollingNumberProps = Omit<ComponentProps<"span">, "children"> & {
|
|
13
|
+
/** 终值 */
|
|
14
|
+
value: number;
|
|
15
|
+
/**
|
|
16
|
+
* 把数值写成字。默认带千分位(`1,280`),小数位数跟着 `value`。
|
|
17
|
+
* 滚的过程中每一帧都会调它
|
|
18
|
+
*/
|
|
19
|
+
format?: (value: number) => string;
|
|
20
|
+
/** 进入视口时从 0 滚到终值,一次。默认开启 */
|
|
21
|
+
animate?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/* 入场的时长:开播时读 `--duration-slower`,读不到就用它的默认值 */
|
|
25
|
+
const FALLBACK_DURATION = 600;
|
|
26
|
+
|
|
27
|
+
function readDuration(node: Element | null): number {
|
|
28
|
+
if (!node) return FALLBACK_DURATION;
|
|
29
|
+
const raw = getComputedStyle(node).getPropertyValue("--duration-slower");
|
|
30
|
+
const amount = Number.parseFloat(raw);
|
|
31
|
+
if (!Number.isFinite(amount) || amount <= 0) return FALLBACK_DURATION;
|
|
32
|
+
return raw.trim().endsWith("ms") ? amount : amount * 1000;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 减速:先快后慢,停得住。和 `ease-exit` 是一类,不带过冲 */
|
|
36
|
+
const decelerate = (progress: number) => 1 - (1 - progress) ** 3;
|
|
37
|
+
|
|
38
|
+
function fractionDigits(value: number): number {
|
|
39
|
+
const fraction = new Intl.NumberFormat("en-US", {
|
|
40
|
+
maximumFractionDigits: 20,
|
|
41
|
+
useGrouping: false,
|
|
42
|
+
})
|
|
43
|
+
.format(value)
|
|
44
|
+
.split(".")[1];
|
|
45
|
+
return fraction ? fraction.length : 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const never = () => () => {};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 滚动数字:进入视口时从 0 滚到终值,只滚一次。放进 `Stat` 的 `value` 里,
|
|
52
|
+
* 或者任何"一个需要被一眼看到的数字"的地方。等宽数字由所在处给(`tabular-nums`)。
|
|
53
|
+
*
|
|
54
|
+
* - 读屏只读终值:终值一直在,滚的时候只是看不见;滚动的那一份对读屏隐藏。
|
|
55
|
+
* - 宽度由终值占着,滚的时候旁边的东西不动。
|
|
56
|
+
* - 服务端渲染出来的、打印出来的、"减少动态效果"下的,都直接是终值。
|
|
57
|
+
* - 值后来变了直接换,不再滚。
|
|
58
|
+
*/
|
|
59
|
+
export function RollingNumber({
|
|
60
|
+
value,
|
|
61
|
+
format,
|
|
62
|
+
animate = true,
|
|
63
|
+
className,
|
|
64
|
+
ref,
|
|
65
|
+
...props
|
|
66
|
+
}: RollingNumberProps) {
|
|
67
|
+
const digits = fractionDigits(value);
|
|
68
|
+
const write =
|
|
69
|
+
format ??
|
|
70
|
+
((amount: number) =>
|
|
71
|
+
amount.toLocaleString("en-US", {
|
|
72
|
+
minimumFractionDigits: digits,
|
|
73
|
+
maximumFractionDigits: digits,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
// 服务端和注水的那一遍是 false:先把终值画出来,脚本到了才从头滚
|
|
77
|
+
const mounted = useSyncExternalStore(
|
|
78
|
+
never,
|
|
79
|
+
() => true,
|
|
80
|
+
() => false,
|
|
81
|
+
);
|
|
82
|
+
const reduced = useReducedMotion();
|
|
83
|
+
const [done, setDone] = useState(false);
|
|
84
|
+
const [shown, setShown] = useState(0);
|
|
85
|
+
const rolling = mounted && animate && !reduced && !done;
|
|
86
|
+
const [inViewRef, inView] = useInView<HTMLSpanElement>({
|
|
87
|
+
disabled: !rolling,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!rolling || !inView) return;
|
|
92
|
+
const duration = readDuration(inViewRef.current);
|
|
93
|
+
let started: number | undefined;
|
|
94
|
+
let frame = requestAnimationFrame(function tick(now) {
|
|
95
|
+
started ??= now;
|
|
96
|
+
const progress = Math.min((now - started) / duration, 1);
|
|
97
|
+
if (progress >= 1) {
|
|
98
|
+
setDone(true);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
setShown(Number((value * decelerate(progress)).toFixed(digits)));
|
|
102
|
+
frame = requestAnimationFrame(tick);
|
|
103
|
+
});
|
|
104
|
+
return () => cancelAnimationFrame(frame);
|
|
105
|
+
}, [rolling, inView, inViewRef, value, digits]);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<span
|
|
109
|
+
{...props}
|
|
110
|
+
ref={mergeRefs(inViewRef, ref)}
|
|
111
|
+
data-rolling={rolling ? "" : undefined}
|
|
112
|
+
className={cn(rolling && "relative inline-block", className)}
|
|
113
|
+
>
|
|
114
|
+
{/* 终值一直在:占着宽度,读屏读的也是它。打印的时候不管滚没滚完都是它 */}
|
|
115
|
+
<span className={rolling ? "opacity-0 print:opacity-100" : undefined}>
|
|
116
|
+
{write(value)}
|
|
117
|
+
</span>
|
|
118
|
+
{rolling && (
|
|
119
|
+
<span aria-hidden="true" className="absolute inset-0 print:hidden">
|
|
120
|
+
{write(shown)}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
</span>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { RollingNumber, type RollingNumberProps } from "./RollingNumber";
|
|
@@ -242,8 +242,8 @@ export function Schedule({
|
|
|
242
242
|
<div
|
|
243
243
|
aria-hidden="true"
|
|
244
244
|
className={cn(
|
|
245
|
-
decor
|
|
246
|
-
"absolute inset-y-0 right-0 z-2",
|
|
245
|
+
// 不用 decor:它在高对比模式下整个去掉,而"今天在哪"是信息
|
|
246
|
+
"pointer-events-none absolute inset-y-0 right-0 z-2 select-none print:hidden",
|
|
247
247
|
AFTER_RAIL,
|
|
248
248
|
dayColumns,
|
|
249
249
|
)}
|
|
@@ -251,7 +251,7 @@ export function Schedule({
|
|
|
251
251
|
<span
|
|
252
252
|
data-today=""
|
|
253
253
|
style={{ gridColumn: todayColumn }}
|
|
254
|
-
className="row-start-1 border-l-2 border-accent-ink"
|
|
254
|
+
className="row-start-1 border-l-2 border-accent-ink forced-colors:border-[Highlight]"
|
|
255
255
|
/>
|
|
256
256
|
</div>
|
|
257
257
|
)}
|
|
@@ -412,6 +412,8 @@ export function ScheduleItem({
|
|
|
412
412
|
// 条目自己是一个容器:窄到放不下类型标签时把它收掉
|
|
413
413
|
"@container relative isolate flex min-w-0 overflow-clip text-sm",
|
|
414
414
|
"has-focus-visible:outline-2 has-focus-visible:-outline-offset-2 has-focus-visible:outline-focus",
|
|
415
|
+
// 高对比模式下底色都被系统换掉,没垫图的条目就只剩名称,看不出从哪天到哪天:补一圈线
|
|
416
|
+
"forced-colors:border forced-colors:border-[CanvasText]",
|
|
415
417
|
banner
|
|
416
418
|
? "items-start justify-between gap-2 bg-surface-muted p-2"
|
|
417
419
|
: // 系统类条目在两个主题下都是炭灰底:control 系列本来就不随主题变
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
type ReactNode,
|
|
8
8
|
} from "react";
|
|
9
9
|
import { useControllableState } from "../../hooks/useControllableState";
|
|
10
|
+
import { useIndicator } from "../../hooks/useIndicator";
|
|
10
11
|
import { cn } from "../../lib/cn";
|
|
12
|
+
import { mergeRefs } from "../../lib/merge-refs";
|
|
13
|
+
import { indicator, indicatorBox } from "../../lib/motion";
|
|
11
14
|
import { useFieldContext } from "../field/Field";
|
|
12
15
|
import {
|
|
13
16
|
controlSize,
|
|
@@ -78,9 +81,12 @@ export function SegmentedControl({
|
|
|
78
81
|
required: requiredProp,
|
|
79
82
|
invalid: invalidProp,
|
|
80
83
|
className,
|
|
84
|
+
ref,
|
|
81
85
|
...props
|
|
82
86
|
}: SegmentedControlProps) {
|
|
83
87
|
const field = useFieldContext();
|
|
88
|
+
// 选中的那块底画在轨道上:换一段时是同一块滑过去
|
|
89
|
+
const trackRef = useIndicator<HTMLDivElement>("[data-selected]");
|
|
84
90
|
const autoName = useId();
|
|
85
91
|
const [current, setCurrent] = useControllableState<string | undefined>({
|
|
86
92
|
value,
|
|
@@ -119,12 +125,19 @@ export function SegmentedControl({
|
|
|
119
125
|
aria-required={required || undefined}
|
|
120
126
|
aria-disabled={disabled || undefined}
|
|
121
127
|
{...props}
|
|
128
|
+
ref={mergeRefs(trackRef, ref)}
|
|
122
129
|
data-variant={variant}
|
|
123
130
|
data-size={size}
|
|
124
131
|
className={cn(
|
|
125
132
|
// 各段等宽,按最宽的那一段定;比容器宽时一起收窄,文字截断
|
|
126
133
|
"inline-grid max-w-full auto-cols-fr grid-flow-col p-0.5 text-ink",
|
|
127
134
|
"transition-colors duration-(--duration-fast) ease-standard",
|
|
135
|
+
indicator,
|
|
136
|
+
indicatorBox,
|
|
137
|
+
"after:bg-surface-inverse has-[[data-selected][data-disabled]]:after:bg-disabled",
|
|
138
|
+
// 高对比模式下底色都被系统换掉,墨块就没了:改用系统的"选中"色(禁用的是灰字色)。
|
|
139
|
+
// 压在它上面的那一段自己写明字色,见 Segment
|
|
140
|
+
"forced-colors:after:bg-[Highlight] forced-colors:has-[[data-selected][data-disabled]]:after:bg-[GrayText]",
|
|
128
141
|
track[variant],
|
|
129
142
|
controlSize[size],
|
|
130
143
|
// 和输入框一样靠边线说状态
|
|
@@ -190,11 +203,19 @@ export function Segment({
|
|
|
190
203
|
disabled ? "cursor-not-allowed" : "cursor-pointer",
|
|
191
204
|
// 选中不加粗:加粗会让这一段变宽,整条跟着跳。靠的是填充反转
|
|
192
205
|
checked
|
|
193
|
-
?
|
|
194
|
-
|
|
195
|
-
|
|
206
|
+
? [
|
|
207
|
+
disabled
|
|
208
|
+
? "bg-disabled text-on-disabled forced-colors:bg-[GrayText] forced-colors:text-[Canvas]"
|
|
209
|
+
: "bg-surface-inverse text-ink-inverse forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]",
|
|
210
|
+
// 轨道量到了位置,底就由轨道上滑动的那一块来画
|
|
211
|
+
"in-data-indicator:bg-transparent forced-colors:in-data-indicator:bg-transparent",
|
|
212
|
+
// 高对比模式:这一段的颜色全由上面的系统色说了算,不让系统再改——
|
|
213
|
+
// 不然它会在字后面垫一块页面色,把"选中"色盖掉。焦点环跟着写明
|
|
214
|
+
"forced-colors:forced-color-adjust-none forced-colors:has-focus-visible:outline-[Highlight]",
|
|
215
|
+
]
|
|
196
216
|
: disabled
|
|
197
|
-
?
|
|
217
|
+
? // 高对比模式下 <label> 的字色一律被换成页面的字色:禁用的写明灰字色
|
|
218
|
+
"text-ink-disabled forced-colors:text-[GrayText]"
|
|
198
219
|
: "text-ink-secondary hover:bg-ink/5 hover:text-ink",
|
|
199
220
|
className,
|
|
200
221
|
)}
|
|
@@ -16,7 +16,10 @@ export type SkeletonProps = Omit<ComponentProps<"span">, "children"> & {
|
|
|
16
16
|
pulse?: boolean;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// 高对比模式下底色被系统换掉,色块就没了:剩一圈 1px 的灰字色轮廓——
|
|
20
|
+
// 和输入框的占位字一个颜色,说的是"这里的内容还没到"
|
|
21
|
+
const fill =
|
|
22
|
+
"block bg-surface-muted forced-colors:outline forced-colors:-outline-offset-1 forced-colors:outline-[GrayText]";
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* 骨架:只画大块,不画细节。
|
|
@@ -79,16 +79,31 @@ const markLeft = (ratio: number) =>
|
|
|
79
79
|
|
|
80
80
|
const thumb = [
|
|
81
81
|
"relative h-5 w-3 bg-surface-inverse shadow-[0_0_0_2px_var(--ef-surface)]",
|
|
82
|
+
// 按键、外部改值时滑过去。拖动中位置必须贴着指针,所以那时候关掉——
|
|
83
|
+
// 点轨道也算:基元在按下的那一刻就开始拖了
|
|
84
|
+
"transition-[inset-inline-start] duration-(--duration-fast) ease-standard data-dragging:transition-none",
|
|
82
85
|
// 触屏的点击区补到 40 × 40px
|
|
83
86
|
"before:absolute before:-inset-x-3.5 before:-inset-y-2.5 before:content-['']",
|
|
84
87
|
// 按住时正中亮一条强调色细线
|
|
85
|
-
"after:absolute after:inset-y-1 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:bg-accent-ink-inverse after:opacity-0 after:content-[''] data-dragging:after:opacity-100",
|
|
88
|
+
"after:absolute after:inset-y-1 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:bg-accent-ink-inverse after:opacity-0 after:transition-opacity after:duration-(--duration-fast) after:ease-standard after:content-[''] data-dragging:after:opacity-100",
|
|
86
89
|
// 菱形方案:滑块是一个 14px 的菱形,按住时中间亮一个小方块
|
|
87
90
|
"choice-diamond:size-3.5 choice-diamond:rotate-45 choice-diamond:after:inset-1 choice-diamond:after:w-auto choice-diamond:after:translate-x-0",
|
|
88
91
|
"data-disabled:bg-ink-disabled",
|
|
92
|
+
// 高对比模式下底色都被系统换掉,滑块就没了:它是控件自己的一块,用按钮的字色
|
|
93
|
+
"forced-colors:bg-[ButtonText] forced-colors:data-disabled:bg-[GrayText]",
|
|
89
94
|
focusRingWithin,
|
|
90
95
|
].join(" ");
|
|
91
96
|
|
|
97
|
+
/*
|
|
98
|
+
* 高对比模式:轨道、走过的一段、滑块都是底色画的,整个控件会不见。
|
|
99
|
+
* 轨道剩一圈 1px 的线(空心),走过的一段是系统"选中"色的实心条——
|
|
100
|
+
* 空心对实心,不靠两种系统色分得开。禁用时都是灰字色
|
|
101
|
+
*/
|
|
102
|
+
const trackForced =
|
|
103
|
+
"forced-colors:outline forced-colors:-outline-offset-1 forced-colors:outline-[ButtonText] forced-colors:data-disabled:outline-[GrayText]";
|
|
104
|
+
const indicatorForced =
|
|
105
|
+
"forced-colors:bg-[Highlight] forced-colors:data-disabled:bg-[GrayText]";
|
|
106
|
+
|
|
92
107
|
/**
|
|
93
108
|
* 滑块:在一个范围里拖着选一个数。要的是确切的数时用 `Stepper`。
|
|
94
109
|
* 每个滑块都要有名称:放进 `Field`,或者自己传 `aria-label`。
|
|
@@ -164,8 +179,14 @@ export function Slider(props: SliderProps) {
|
|
|
164
179
|
controlSize[size],
|
|
165
180
|
)}
|
|
166
181
|
>
|
|
167
|
-
<BaseSlider.Track className="h-1 w-full bg-line">
|
|
168
|
-
|
|
182
|
+
<BaseSlider.Track className={cn("h-1 w-full bg-line", trackForced)}>
|
|
183
|
+
{/* 走过的一段跟着滑块头走:范围滑块两头都会动 */}
|
|
184
|
+
<BaseSlider.Indicator
|
|
185
|
+
className={cn(
|
|
186
|
+
"bg-ink transition-[inset-inline-start,width] duration-(--duration-fast) ease-standard data-disabled:bg-ink-disabled data-dragging:transition-none",
|
|
187
|
+
indicatorForced,
|
|
188
|
+
)}
|
|
189
|
+
/>
|
|
169
190
|
{(range ? [0, 1] : [0]).map((index) => (
|
|
170
191
|
<BaseSlider.Thumb
|
|
171
192
|
key={index}
|
|
@@ -187,7 +208,7 @@ export function Slider(props: SliderProps) {
|
|
|
187
208
|
key={mark.value}
|
|
188
209
|
style={{ left: markLeft((mark.value - min) / (max - min)) }}
|
|
189
210
|
className={cn(
|
|
190
|
-
"absolute top-0 flex flex-col gap-0.5 whitespace-nowrap text-ink-secondary before:h-1.5 before:w-px before:bg-line-strong before:content-['']",
|
|
211
|
+
"absolute top-0 flex flex-col gap-0.5 whitespace-nowrap text-ink-secondary before:h-1.5 before:w-px before:bg-line-strong before:content-[''] forced-colors:before:bg-[ButtonText]",
|
|
191
212
|
// 两端的标注朝里排,不伸到控件外面去;短线仍然对着滑块的中心
|
|
192
213
|
mark.value <= min
|
|
193
214
|
? "items-start"
|
|
@@ -207,7 +228,7 @@ export function Slider(props: SliderProps) {
|
|
|
207
228
|
<BaseSlider.Value
|
|
208
229
|
style={{ minWidth: valueWidth }}
|
|
209
230
|
className={cn(
|
|
210
|
-
"flex shrink-0 items-center justify-end font-tech text-sm font-bold tabular-nums data-disabled:text-ink-disabled",
|
|
231
|
+
"flex shrink-0 items-center justify-end font-tech text-sm font-bold tabular-nums data-disabled:text-ink-disabled forced-colors:data-disabled:text-[GrayText]",
|
|
211
232
|
controlSize[size],
|
|
212
233
|
)}
|
|
213
234
|
>
|