@endfield-ui/react 0.3.0 → 1.0.1

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.
Files changed (70) hide show
  1. package/NOTICE.md +2 -2
  2. package/README.md +103 -8
  3. package/dist/index.d.ts +69 -15
  4. package/dist/index.js +1104 -239
  5. package/dist/styles.css +1 -1
  6. package/package.json +1 -1
  7. package/src/components/README.md +12 -3
  8. package/src/components/alert/Alert.tsx +63 -1
  9. package/src/components/avatar/Avatar.tsx +5 -2
  10. package/src/components/bracket-title/BracketTitle.tsx +32 -3
  11. package/src/components/button/Button.tsx +37 -5
  12. package/src/components/calendar/Calendar.tsx +49 -5
  13. package/src/components/carousel/Carousel.tsx +2 -1
  14. package/src/components/checkbox/Checkbox.tsx +1 -0
  15. package/src/components/checkbox/choice-style.ts +9 -4
  16. package/src/components/chip/capsule-style.ts +18 -3
  17. package/src/components/combobox/Combobox.tsx +10 -0
  18. package/src/components/completion-banner/CompletionBanner.tsx +30 -2
  19. package/src/components/dash-indicator/DashIndicator.tsx +10 -3
  20. package/src/components/data-row/DataRow.tsx +2 -1
  21. package/src/components/date-picker/DateField.tsx +5 -1
  22. package/src/components/dropdown-menu/MenuCheck.tsx +4 -2
  23. package/src/components/dropdown-menu/menu-style.ts +13 -3
  24. package/src/components/empty-state/EmptyState.tsx +5 -0
  25. package/src/components/field/Field.tsx +39 -7
  26. package/src/components/file-upload/FileUpload.tsx +78 -19
  27. package/src/components/icon-button/IconButton.tsx +18 -5
  28. package/src/components/input/control-box.ts +23 -2
  29. package/src/components/input/value-chip.ts +3 -2
  30. package/src/components/loader/Loader.tsx +12 -41
  31. package/src/components/nav-menu/NavMenu.tsx +3 -2
  32. package/src/components/otp-input/OtpInput.tsx +8 -2
  33. package/src/components/page-header/PageHeader.tsx +39 -3
  34. package/src/components/pagination/Pagination.tsx +2 -0
  35. package/src/components/progress/Progress.tsx +10 -3
  36. package/src/components/progress/ProgressRing.tsx +19 -4
  37. package/src/components/radio/Radio.tsx +2 -0
  38. package/src/components/rolling-number/RollingNumber.tsx +125 -0
  39. package/src/components/rolling-number/index.ts +1 -0
  40. package/src/components/schedule/Schedule.tsx +5 -3
  41. package/src/components/segmented-control/SegmentedControl.tsx +25 -4
  42. package/src/components/skeleton/Skeleton.tsx +4 -1
  43. package/src/components/slider/Slider.tsx +21 -4
  44. package/src/components/sparkline/Sparkline.tsx +29 -5
  45. package/src/components/spinner/Spinner.tsx +6 -2
  46. package/src/components/stepper/Stepper.tsx +4 -0
  47. package/src/components/steps/Steps.tsx +18 -6
  48. package/src/components/switch/Switch.tsx +26 -2
  49. package/src/components/table/Table.tsx +33 -9
  50. package/src/components/tabs/Tabs.tsx +55 -9
  51. package/src/components/tag-input/TagInput.tsx +82 -43
  52. package/src/components/textarea/Textarea.tsx +2 -1
  53. package/src/components/timeline/Timeline.tsx +4 -3
  54. package/src/components/toc/Toc.tsx +89 -7
  55. package/src/components/toolbar/Toolbar.tsx +24 -0
  56. package/src/hooks/README.md +8 -2
  57. package/src/hooks/index.ts +1 -0
  58. package/src/hooks/useAddedKeys.ts +26 -0
  59. package/src/hooks/useFlip.ts +211 -0
  60. package/src/hooks/useIndicator.ts +135 -0
  61. package/src/hooks/useLingering.ts +122 -0
  62. package/src/hooks/usePresence.ts +100 -0
  63. package/src/hooks/useReducedMotion.ts +26 -0
  64. package/src/hooks/useTheme.ts +44 -4
  65. package/src/index.ts +1 -0
  66. package/src/lib/README.md +2 -1
  67. package/src/lib/cn.ts +1 -0
  68. package/src/lib/inline-style.ts +12 -0
  69. package/src/lib/motion.ts +75 -0
  70. package/src/styles/theme.css +38 -2
@@ -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 && <p className="font-tech text-xs text-ink-secondary">{meta}</p>}
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 className="min-w-0 text-2xl leading-10 font-bold wrap-anywhere @md:text-3xl @md:leading-12">
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 className="max-w-prose text-sm text-ink-secondary">
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 : "bg-line")}
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 && "bg-line",
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="origin-center animate-spin stroke-action motion-reduce:hidden"
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 stroke-action motion-reduce:block"
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="stroke-action transition-[stroke-dashoffset] duration-(--duration-base) ease-standard"
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="stroke-line" />
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
- ? disabled
194
- ? "bg-disabled text-on-disabled"
195
- : "bg-surface-inverse text-ink-inverse"
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
- ? "text-ink-disabled"
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
- const fill = "block bg-surface-muted";
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
  * 骨架:只画大块,不画细节。
@@ -89,9 +89,21 @@ const thumb = [
89
89
  // 菱形方案:滑块是一个 14px 的菱形,按住时中间亮一个小方块
90
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",
91
91
  "data-disabled:bg-ink-disabled",
92
+ // 高对比模式下底色都被系统换掉,滑块就没了:它是控件自己的一块,用按钮的字色
93
+ "forced-colors:bg-[ButtonText] forced-colors:data-disabled:bg-[GrayText]",
92
94
  focusRingWithin,
93
95
  ].join(" ");
94
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
+
95
107
  /**
96
108
  * 滑块:在一个范围里拖着选一个数。要的是确切的数时用 `Stepper`。
97
109
  * 每个滑块都要有名称:放进 `Field`,或者自己传 `aria-label`。
@@ -167,9 +179,14 @@ export function Slider(props: SliderProps) {
167
179
  controlSize[size],
168
180
  )}
169
181
  >
170
- <BaseSlider.Track className="h-1 w-full bg-line">
182
+ <BaseSlider.Track className={cn("h-1 w-full bg-line", trackForced)}>
171
183
  {/* 走过的一段跟着滑块头走:范围滑块两头都会动 */}
172
- <BaseSlider.Indicator className="bg-ink transition-[inset-inline-start,width] duration-(--duration-fast) ease-standard data-disabled:bg-ink-disabled data-dragging:transition-none" />
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
+ />
173
190
  {(range ? [0, 1] : [0]).map((index) => (
174
191
  <BaseSlider.Thumb
175
192
  key={index}
@@ -191,7 +208,7 @@ export function Slider(props: SliderProps) {
191
208
  key={mark.value}
192
209
  style={{ left: markLeft((mark.value - min) / (max - min)) }}
193
210
  className={cn(
194
- "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]",
195
212
  // 两端的标注朝里排,不伸到控件外面去;短线仍然对着滑块的中心
196
213
  mark.value <= min
197
214
  ? "items-start"
@@ -211,7 +228,7 @@ export function Slider(props: SliderProps) {
211
228
  <BaseSlider.Value
212
229
  style={{ minWidth: valueWidth }}
213
230
  className={cn(
214
- "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]",
215
232
  controlSize[size],
216
233
  )}
217
234
  >
@@ -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
- /* data 与 alert 是不随主题变的角色色:白底和深色行带上都认得出 */
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("block h-8 w-full overflow-visible", className)}
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
- /* 线和节点之间留 8px,不从节点后面穿过 */
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
- current: "border border-ink bg-action font-bold text-on-action",
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
 
@@ -267,6 +278,7 @@ export function Step({
267
278
  <span
268
279
  className={cn(
269
280
  "relative flex-1 overflow-clip bg-line-strong group-last/step:hidden",
281
+ "forced-colors:border-[CanvasText] forced-colors:before:bg-[CanvasText]",
270
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-['']",
271
283
  lineLayout[orientation],
272
284
  status === "done" ? "before:scale-100" : "before:scale-0",