@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.
Files changed (77) hide show
  1. package/NOTICE.md +2 -2
  2. package/README.md +103 -8
  3. package/dist/index.d.ts +78 -17
  4. package/dist/index.js +1184 -264
  5. package/dist/styles.css +1 -1
  6. package/package.json +1 -1
  7. package/src/components/README.md +11 -2
  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/checkbox/Checkbox.tsx +1 -0
  14. package/src/components/checkbox/choice-style.ts +13 -5
  15. package/src/components/chip/capsule-style.ts +18 -3
  16. package/src/components/combobox/Combobox.tsx +10 -0
  17. package/src/components/completion-banner/CompletionBanner.tsx +30 -2
  18. package/src/components/corner-brackets/CornerBrackets.tsx +2 -1
  19. package/src/components/dash-indicator/DashIndicator.tsx +5 -2
  20. package/src/components/data-row/DataRow.tsx +2 -1
  21. package/src/components/date-picker/DateField.tsx +5 -1
  22. package/src/components/dialog/Dialog.tsx +4 -2
  23. package/src/components/dropdown-menu/MenuCheck.tsx +4 -2
  24. package/src/components/dropdown-menu/menu-style.ts +22 -5
  25. package/src/components/empty-state/EmptyState.tsx +5 -0
  26. package/src/components/field/Field.tsx +39 -7
  27. package/src/components/file-upload/FileUpload.tsx +79 -20
  28. package/src/components/icon-button/IconButton.tsx +18 -5
  29. package/src/components/image-viewer/ImageViewer.tsx +58 -18
  30. package/src/components/input/control-box.ts +23 -2
  31. package/src/components/input/value-chip.ts +3 -2
  32. package/src/components/item-slot/ItemSlot.tsx +2 -1
  33. package/src/components/loader/Loader.tsx +12 -41
  34. package/src/components/nav-menu/NavMenu.tsx +17 -3
  35. package/src/components/otp-input/OtpInput.tsx +13 -4
  36. package/src/components/page-header/PageHeader.tsx +39 -3
  37. package/src/components/pagination/Pagination.tsx +2 -0
  38. package/src/components/progress/Progress.tsx +10 -3
  39. package/src/components/progress/ProgressRing.tsx +19 -4
  40. package/src/components/radio/Radio.tsx +2 -0
  41. package/src/components/rolling-number/RollingNumber.tsx +125 -0
  42. package/src/components/rolling-number/index.ts +1 -0
  43. package/src/components/schedule/Schedule.tsx +5 -3
  44. package/src/components/segmented-control/SegmentedControl.tsx +25 -4
  45. package/src/components/skeleton/Skeleton.tsx +4 -1
  46. package/src/components/slider/Slider.tsx +26 -5
  47. package/src/components/sparkline/Sparkline.tsx +29 -5
  48. package/src/components/spinner/Spinner.tsx +6 -2
  49. package/src/components/stepper/Stepper.tsx +4 -0
  50. package/src/components/steps/Steps.tsx +30 -9
  51. package/src/components/switch/Switch.tsx +26 -2
  52. package/src/components/table/Table.tsx +33 -9
  53. package/src/components/tabs/Tabs.tsx +55 -9
  54. package/src/components/tag-input/TagInput.tsx +82 -43
  55. package/src/components/textarea/Textarea.tsx +2 -1
  56. package/src/components/timeline/Timeline.tsx +4 -3
  57. package/src/components/toast/Toast.tsx +18 -5
  58. package/src/components/toc/Toc.tsx +89 -7
  59. package/src/components/toolbar/Toolbar.tsx +24 -0
  60. package/src/components/tooltip/Tooltip.tsx +5 -1
  61. package/src/components/viewfinder/Viewfinder.tsx +9 -0
  62. package/src/hooks/README.md +8 -2
  63. package/src/hooks/index.ts +1 -0
  64. package/src/hooks/useAddedKeys.ts +26 -0
  65. package/src/hooks/useFlip.ts +211 -0
  66. package/src/hooks/useIndicator.ts +135 -0
  67. package/src/hooks/useLingering.ts +122 -0
  68. package/src/hooks/usePresence.ts +100 -0
  69. package/src/hooks/useReducedMotion.ts +26 -0
  70. package/src/hooks/useTheme.ts +44 -4
  71. package/src/index.ts +1 -0
  72. package/src/lib/README.md +2 -0
  73. package/src/lib/cn.ts +3 -0
  74. package/src/lib/inline-style.ts +12 -0
  75. package/src/lib/motion.ts +90 -0
  76. package/src/styles/theme.css +81 -2
  77. 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
- "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-on-action",
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 type { ControlSize, ControlVariant } from "../input/control-box";
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
- "min-w-0 shrink text-center font-tech font-medium tabular-nums text-ink caret-ink",
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 text-ink-disabled"
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
- className="h-0.5 w-2 shrink-0 bg-line-strong"
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 && <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
  * 骨架:只画大块,不画细节。
@@ -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
- <BaseSlider.Indicator className="bg-ink data-disabled:bg-ink-disabled" />
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
  >