@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
@@ -10,14 +10,19 @@ import {
10
10
  type KeyboardEvent,
11
11
  type MouseEvent,
12
12
  } from "react";
13
+ import { useAddedKeys } from "../../hooks/useAddedKeys";
13
14
  import { useControllableState } from "../../hooks/useControllableState";
15
+ import { FLIP_LIMIT, useFlip } from "../../hooks/useFlip";
16
+ import { useLingering } from "../../hooks/useLingering";
14
17
  import { Close } from "../../icons/Close";
15
18
  import { StatusDanger } from "../../icons/StatusDanger";
16
19
  import { cn } from "../../lib/cn";
17
20
  import { mergeRefs } from "../../lib/merge-refs";
21
+ import { fadeInFast, flipBox, flipLeaving } from "../../lib/motion";
18
22
  import { useFieldControl } from "../field/Field";
19
23
  import {
20
24
  controlBox,
25
+ controlDisabledText,
21
26
  controlElement,
22
27
  controlIconSize,
23
28
  type ControlSize,
@@ -182,6 +187,15 @@ export function TagInput({
182
187
  const [flash, setFlash] = useState<string | null>(null);
183
188
  const [message, setMessage] = useState("");
184
189
  const full = max !== undefined && tags.length >= max;
190
+ // 后来加的那几个淡入;一开始就有的是静止的
191
+ const added = useAddedKeys(tags);
192
+ // 删掉的那一块先留着把淡出走完,其余的小块和输入框同时补位。
193
+ // 补位的钩子排在前面:它钉不住的(小块太多、量不到位置)当场藏掉,后面那个就不等了
194
+ const rowRef = useFlip<HTMLDivElement>();
195
+ const lingering = useLingering<HTMLDivElement>(
196
+ tags,
197
+ tags.length < FLIP_LIMIT,
198
+ );
185
199
 
186
200
  useEffect(() => {
187
201
  if (flash === null) return;
@@ -367,48 +381,71 @@ export function TagInput({
367
381
  className,
368
382
  )}
369
383
  >
370
- <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1 py-1 pl-2">
371
- {tags.map((tag, index) => (
372
- <span
373
- key={tag}
374
- data-tag=""
375
- data-flash={flash === tag ? "" : undefined}
376
- className={cn(
377
- valueChip,
378
- valueChipSize[size],
379
- "transition-colors duration-(--duration-fast) ease-standard",
380
- // 重复时闪一下:填充反转。不用黄色——这不是"选中"
381
- "data-flash:bg-surface-inverse data-flash:text-ink-inverse",
382
- disabled && "text-ink-disabled",
383
- !editable && "pr-2",
384
- )}
385
- >
386
- <span className="min-w-0 truncate">{tag}</span>
387
- {editable && (
388
- <button
389
- type="button"
390
- // 整个控件只占一个 Tab 停靠点:小块用方向键走
391
- tabIndex={-1}
392
- aria-label={removeLabel(tag)}
393
- onClick={(event) =>
394
- // 键盘触发的(回车、空格)焦点留在小块这一排;鼠标点的回到输入框
395
- remove(
396
- index,
397
- event.detail === 0 && tags.length > 1 ? index : "input",
398
- )
399
- }
400
- onKeyDown={(event) => handleChipKeyDown(event, index)}
401
- className={cn(
402
- valueChipRemove,
403
- "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus",
404
- flash === tag && "text-ink-inverse",
405
- )}
406
- >
407
- <Close size={12} />
408
- </button>
409
- )}
410
- </span>
411
- ))}
384
+ <div
385
+ ref={mergeRefs(rowRef, lingering.ref)}
386
+ className={cn(
387
+ "flex min-w-0 flex-1 flex-wrap items-center gap-1 py-1 pl-2",
388
+ flipBox,
389
+ )}
390
+ >
391
+ {lingering.entries.map(({ key: tag, leaving }) => {
392
+ // 正在走的那一块已经不在 tags 里了,下面按位置算的事它都用不上
393
+ const index = tags.indexOf(tag);
394
+ return (
395
+ <span
396
+ key={tag}
397
+ // 正在走的不算数:删掉之后焦点落到哪、整排怎么用方向键走,看到的只有还在的
398
+ data-tag={leaving ? undefined : ""}
399
+ data-leaving={leaving ? "" : undefined}
400
+ data-flash={flash === tag && !leaving ? "" : undefined}
401
+ // 它点不到、Tab 不进去,读屏也读不到:状态在删的那一刻就变了,画面后到
402
+ inert={leaving}
403
+ aria-hidden={leaving || undefined}
404
+ className={cn(
405
+ valueChip,
406
+ valueChipSize[size],
407
+ // 换色、补位时滑回原位、走的时候淡出,都是 200ms
408
+ "transition-[color,background-color,translate,opacity] duration-(--duration-fast) ease-standard",
409
+ // 重复时闪一下:填充反转。不用黄色——这不是"选中"
410
+ "data-flash:bg-surface-inverse data-flash:text-ink-inverse",
411
+ // 高对比模式下底色被系统换掉,反转闪不出来:闪的时候套一圈系统"选中"色的环,
412
+ // 画在那一圈线的外面(小块之间有 4px,放得下)
413
+ "forced-colors:data-flash:outline-2 forced-colors:data-flash:outline-[Highlight]",
414
+ flipLeaving,
415
+ added.has(tag) && fadeInFast,
416
+ // 高对比模式:这是个 <span>,系统不知道它禁用了,字和那一圈线都写明灰字色
417
+ disabled &&
418
+ "text-ink-disabled forced-colors:border-[GrayText] forced-colors:text-[GrayText]",
419
+ !editable && "pr-2",
420
+ )}
421
+ >
422
+ <span className="min-w-0 truncate">{tag}</span>
423
+ {editable && (
424
+ <button
425
+ type="button"
426
+ // 整个控件只占一个 Tab 停靠点:小块用方向键走
427
+ tabIndex={-1}
428
+ aria-label={removeLabel(tag)}
429
+ onClick={(event) =>
430
+ // 键盘触发的(回车、空格)焦点留在小块这一排;鼠标点的回到输入框
431
+ remove(
432
+ index,
433
+ event.detail === 0 && tags.length > 1 ? index : "input",
434
+ )
435
+ }
436
+ onKeyDown={(event) => handleChipKeyDown(event, index)}
437
+ className={cn(
438
+ valueChipRemove,
439
+ "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus",
440
+ flash === tag && "text-ink-inverse",
441
+ )}
442
+ >
443
+ <Close size={12} />
444
+ </button>
445
+ )}
446
+ </span>
447
+ );
448
+ })}
412
449
  <input
413
450
  {...props}
414
451
  ref={mergeRefs(inputRef, ref)}
@@ -446,6 +483,8 @@ export function TagInput({
446
483
  controlElement,
447
484
  "min-w-12 px-1",
448
485
  valueInputHeight[size],
486
+ // 删了一个小块:和后面的小块一起滑到新位置
487
+ "transition-[translate] duration-(--duration-fast) ease-standard",
449
488
  )}
450
489
  />
451
490
  </div>
@@ -469,7 +508,7 @@ export function TagInput({
469
508
  className={cn(
470
509
  "font-tech text-xs tabular-nums",
471
510
  disabled
472
- ? "text-ink-disabled"
511
+ ? controlDisabledText
473
512
  : full
474
513
  ? "text-ink"
475
514
  : "text-ink-secondary",
@@ -3,6 +3,7 @@ import { cn } from "../../lib/cn";
3
3
  import { useFieldControl } from "../field/Field";
4
4
  import {
5
5
  controlBox,
6
+ controlDisabledText,
6
7
  controlElement,
7
8
  type ControlVariant,
8
9
  } from "../input/control-box";
@@ -88,7 +89,7 @@ export function Textarea({
88
89
  data-count=""
89
90
  className={cn(
90
91
  "pointer-events-none self-end px-3 pb-1.5 font-tech text-xs tabular-nums",
91
- disabled ? "text-ink-disabled" : "text-ink-secondary",
92
+ disabled ? controlDisabledText : "text-ink-secondary",
92
93
  )}
93
94
  >
94
95
  {maxLength !== undefined ? `${length} / ${maxLength}` : length}
@@ -25,9 +25,10 @@ export type TimelineItemProps = Omit<ComponentProps<"li">, "title"> & {
25
25
  };
26
26
 
27
27
  const nodeClass: Record<TimelineStatus, string> = {
28
- past: "bg-ink",
28
+ // 高对比模式下底色都被系统换掉,实心的菱形就没了:换成页面的字色
29
+ past: "bg-ink forced-colors:bg-[CanvasText]",
29
30
  // 描一圈最细的线(1px),颜色跟着文字走:亮色下是墨色,暗色下是近白
30
- current: "border border-ink bg-action",
31
+ current: "border border-ink bg-action forced-colors:bg-[Highlight]",
31
32
  upcoming: "border-2 border-ink-tertiary",
32
33
  };
33
34
 
@@ -66,7 +67,7 @@ export function TimelineItem({
66
67
  <span className={cn("size-[11px] rotate-45", nodeClass[status])} />
67
68
  </span>
68
69
  {/* 轴只画在节点之间,不从节点后面穿过,空心菱形才是空的 */}
69
- <span className="w-0.5 flex-1 bg-line-strong group-last/item:hidden" />
70
+ <span className="w-0.5 flex-1 bg-line-strong group-last/item:hidden forced-colors:bg-[CanvasText]" />
70
71
  </span>
71
72
  <div className="min-w-0 flex-1 pb-6 group-last/item:pb-0">
72
73
  {hasDate && (
@@ -82,7 +82,15 @@ const toneText: Record<Exclude<ToastTone, "neutral">, string> = {
82
82
 
83
83
  /*
84
84
  * 水平居中 + 跟着手指走的位移(可以往下或往右划走)。
85
- * 新旧两条在同一个位置交替淡入淡出,所以用绝对定位叠在一起
85
+ *
86
+ * 写在 transform 上,不能写在 translate 上:基元在拖动的时候自己往元素上写内联的
87
+ * transform(从元素现在的 transform 出发,加上指针走过的距离),并把过渡关掉;松手
88
+ * 之后交还给这里的变量。位置写在别的属性上的话两处会叠加——提示走的是手指的两倍。
89
+ *
90
+ * 进场的位移另写在 translate 上(只在起点有):两个属性各管各的,"滑入"的过渡碰不到
91
+ * "跟手"的那一份。底部的从下面来;正中的是官网的做法,只有透明度。
92
+ *
93
+ * 新旧两条在同一个位置交替,所以用绝对定位叠在一起:旧的原地淡出,新的升上来
86
94
  */
87
95
  const placementClass: Record<
88
96
  ToastPlacement,
@@ -90,11 +98,11 @@ const placementClass: Record<
90
98
  > = {
91
99
  bottom: {
92
100
  viewport: "bottom-6",
93
- root: "bottom-0 translate-y-[var(--toast-swipe-movement-y,0px)]",
101
+ root: "bottom-0 transform-[translate(calc(-50%+var(--toast-swipe-movement-x,0px)),var(--toast-swipe-movement-y,0px))] data-starting-style:translate-y-(--motion-shift-lg)",
94
102
  },
95
103
  center: {
96
104
  viewport: "top-1/2",
97
- root: "top-0 translate-y-[calc(-50%+var(--toast-swipe-movement-y,0px))]",
105
+ root: "top-0 transform-[translate(calc(-50%+var(--toast-swipe-movement-x,0px)),calc(-50%+var(--toast-swipe-movement-y,0px)))]",
98
106
  },
99
107
  };
100
108
 
@@ -169,11 +177,16 @@ function ToastHost({ placement, label, closeLabel, children }: ToastHostProps) {
169
177
  // 黑底白字不随主题变:整条做成暗色的局部主题,图形和焦点环按深色底取值
170
178
  data-theme="dark"
171
179
  className={cn(
172
- "pointer-events-auto absolute left-1/2 w-max max-w-full translate-x-[calc(-50%+var(--toast-swipe-movement-x,0px))]",
180
+ "pointer-events-auto absolute left-1/2 w-max max-w-full",
173
181
  // 边线是给暗色页面的:那里黑底和页面几乎一样深,要靠它把提示勾出来。
174
182
  // 亮色页面上它和提示的底几乎同色,看不出来
175
183
  "border border-line bg-black/80 text-base leading-[1.4] text-white",
176
- "transition-opacity duration-(--duration-fast) ease-standard data-ending-style:opacity-0 data-starting-style:opacity-0",
184
+ // 三样各走各的:淡入淡出 200ms;进场的位移 300ms;没划够就松手,滑回原位 200ms。
185
+ // 拖动中基元把过渡整个关掉,位置贴着手指
186
+ "transition-[opacity,translate,transform] duration-[var(--duration-fast),var(--duration-base),var(--duration-fast)] ease-[var(--ease-standard),var(--ease-exit),var(--ease-standard)]",
187
+ "data-ending-style:opacity-0 data-starting-style:opacity-0",
188
+ // 拖的时候不选中文字:用鼠标拖会把字选上,下一次再拖,浏览器当成"拖这段字",手势就被取消了
189
+ "data-swiping:select-none",
177
190
  // 焦点环画在里面:它是按深色底取的颜色,画到外面会压在亮色的页面上
178
191
  focusRingInset,
179
192
  placementClass[placement].root,
@@ -13,13 +13,16 @@ import {
13
13
  type ReactNode,
14
14
  type RefObject,
15
15
  } from "react";
16
+ import { useIndicator } from "../../hooks/useIndicator";
16
17
  import {
17
18
  useScrollPosition,
18
19
  type ScrollMetrics,
19
20
  } from "../../hooks/useScrollPosition";
20
21
  import { cn } from "../../lib/cn";
21
22
  import { focusRingInset } from "../../lib/focus-ring";
23
+ import { dropEmptyStyle } from "../../lib/inline-style";
22
24
  import { mergeRefs } from "../../lib/merge-refs";
25
+ import { indicator } from "../../lib/motion";
23
26
  import { LinkElement, type LinkRender } from "../link-element/LinkElement";
24
27
 
25
28
  export type TocLevel = 1 | 2 | 3;
@@ -51,11 +54,50 @@ export type TocProps = TocOwnProps &
51
54
  /** 点了一项之后,多久没有滚动事件算这一次滚动停下了 */
52
55
  const SETTLE_MS = 150;
53
56
 
57
+ /**
58
+ * 让接下来的这一次滚动是平滑的:给这几个滚动容器写上内联的 `scroll-behavior`,
59
+ * 滚动停下(`SETTLE_MS` 里没有再滚)就还原成原来的内联值。返回的函数提前还原。
60
+ *
61
+ * 滚动本身不归它管——跳过去的仍然是浏览器(锚点跳转)。
62
+ */
63
+ function smoothOnce(scrollers: readonly HTMLElement[]) {
64
+ const before = scrollers.map((scroller) =>
65
+ scroller.style.getPropertyValue("scroll-behavior"),
66
+ );
67
+ for (const scroller of scrollers) {
68
+ scroller.style.setProperty("scroll-behavior", "smooth");
69
+ }
70
+
71
+ // 页面的滚动事件发在 window 上,容器的发在它自己身上
72
+ const sources = scrollers.map((scroller) =>
73
+ scroller === document.documentElement ? window : scroller,
74
+ );
75
+ let timer: ReturnType<typeof setTimeout>;
76
+ const restore = () => {
77
+ clearTimeout(timer);
78
+ for (const source of sources) source.removeEventListener("scroll", wait);
79
+ scrollers.forEach((scroller, index) => {
80
+ scroller.style.setProperty("scroll-behavior", before[index]!);
81
+ dropEmptyStyle(scroller);
82
+ });
83
+ };
84
+ function wait() {
85
+ clearTimeout(timer);
86
+ timer = setTimeout(restore, SETTLE_MS);
87
+ }
88
+ for (const source of sources) {
89
+ source.addEventListener("scroll", wait, { passive: true });
90
+ }
91
+ wait();
92
+ return restore;
93
+ }
94
+
54
95
  /**
55
96
  * 页内目录:这一页有哪几节、现在看到了哪一节。里面放 `TocItem`。
56
97
  *
57
- * 位置由使用方给,通常是 `className="sticky top-6"`。点一项就是点一个锚点链接:
58
- * 滚不滚、平不平滑是浏览器和页面 CSS 的事,它不接管滚动。
98
+ * 位置由使用方给,通常是 `className="sticky top-6"`。点一项仍然是点一个锚点链接——
99
+ * 地址、后退、`scroll-mt` 都是浏览器原生的那一套;它只让这一次跳转滚得平滑
100
+ * (系统开了"减少动态效果"时照旧直接跳)。
59
101
  */
60
102
  export function Toc({
61
103
  title,
@@ -69,6 +111,8 @@ export function Toc({
69
111
  ...props
70
112
  }: TocProps) {
71
113
  const navRef = useRef<HTMLElement>(null);
114
+ // 当前项的粗条画在列表上:换一项时是同一段沿引线滑过去
115
+ const listRef = useIndicator<HTMLUListElement>("[aria-current]");
72
116
  const titleId = useId();
73
117
  // 目录的项数变了(内容是后来才到的)要重新算一次,不等下一次滚动
74
118
  const count = Children.count(children);
@@ -133,6 +177,32 @@ export function Toc({
133
177
 
134
178
  const active = pinned ?? spied;
135
179
 
180
+ // 点了一项之后的那一次跳转滚得平滑。这是脚本里要的平滑滚动,"减少动态效果"得自己看
181
+ const restoreScroll = useRef<(() => void) | null>(null);
182
+ useEffect(() => () => restoreScroll.current?.(), []);
183
+ const pick = useCallback(
184
+ (section: string) => {
185
+ setPinned(section);
186
+ restoreScroll.current?.();
187
+ restoreScroll.current = null;
188
+ if (!document.getElementById(section)) return;
189
+ if (
190
+ typeof window.matchMedia === "function" &&
191
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
192
+ ) {
193
+ return;
194
+ }
195
+ // 小节在容器里时,页面也可能跟着滚(把它带到眼前):两处一起
196
+ const container = target?.current;
197
+ restoreScroll.current = smoothOnce(
198
+ container
199
+ ? [container, document.documentElement]
200
+ : [document.documentElement],
201
+ );
202
+ },
203
+ [target],
204
+ );
205
+
136
206
  const report = useRef(onActiveChange);
137
207
  report.current = onActiveChange;
138
208
  const reported = useRef<string | null>(null);
@@ -158,7 +228,7 @@ export function Toc({
158
228
  }
159
229
  }, [active]);
160
230
 
161
- const context = useMemo(() => ({ active, pick: setPinned }), [active]);
231
+ const context = useMemo(() => ({ active, pick }), [active, pick]);
162
232
  const hasTitle = title !== undefined && title !== null && title !== false;
163
233
 
164
234
  return (
@@ -179,8 +249,19 @@ export function Toc({
179
249
  {title}
180
250
  </p>
181
251
  )}
182
- {/* 引线:从头贯到尾的一条细线,当前项那一段由那一项自己换成粗条 */}
183
- <ul className="relative before:absolute before:inset-y-0 before:left-0 before:w-px before:bg-line before:content-['']">
252
+ {/* 引线:从头贯到尾的一条细线;当前项那一段是压在它上面的粗条 */}
253
+ <ul
254
+ ref={listRef}
255
+ className={cn(
256
+ // 和粗条同一层:它在前、粗条在后,粗条压着它;两样都在各项之下
257
+ "before:absolute before:inset-y-0 before:left-0 before:-z-1 before:w-px before:bg-line before:content-['']",
258
+ indicator,
259
+ // 3px 宽,比当前项上下各短 4px;长度跟着那一项的高度走
260
+ "after:top-1 after:h-[calc(var(--indicator-h)-0.5rem)] after:w-[3px] after:translate-y-(--indicator-y) after:bg-ink",
261
+ // 高对比模式下底色被系统换掉,粗条就没了:改用系统的"选中"色
262
+ "forced-colors:after:bg-[Highlight]",
263
+ )}
264
+ >
184
265
  <TocContext value={context}>{children}</TocContext>
185
266
  </ul>
186
267
  </nav>
@@ -251,8 +332,9 @@ export function TocItem({
251
332
  focusRingInset,
252
333
  levelPadding[level],
253
334
  current
254
- ? // 不加粗:加粗会让折行变掉,整列目录跟着跳。靠的是引线上的这一段粗条
255
- "text-ink before:absolute before:inset-y-1 before:left-0 before:w-[3px] before:bg-ink before:content-['']"
335
+ ? // 不加粗:加粗会让折行变掉,整列目录跟着跳。靠的是引线上的这一段粗条——
336
+ // 列表量到了位置就由列表上滑动的那一段来画,这里的让出来
337
+ "text-ink before:absolute before:inset-y-1 before:left-0 before:w-[3px] before:bg-ink before:content-[''] in-data-indicator:before:hidden forced-colors:before:bg-[Highlight]"
256
338
  : "text-ink-secondary hover:text-ink",
257
339
  )}
258
340
  >
@@ -48,6 +48,18 @@ const barVariant: Record<ToolbarVariant, string> = {
48
48
  outline: "border border-line-strong bg-surface p-px",
49
49
  };
50
50
 
51
+ /*
52
+ * 高对比模式下凹陷的底被系统换成页面色,带子就没了:补一圈线。里面没按下的钮平时就是透明的,
53
+ * 靠带子围着,所以补的是带子,不是每个钮。线画在内边距里,不占地方;描边的那种本来就有线,
54
+ * 只把颜色写明。整条禁用时是灰字色
55
+ */
56
+ const barForced: Record<ToolbarVariant, string> = {
57
+ sunken:
58
+ "forced-colors:outline forced-colors:-outline-offset-1 forced-colors:outline-[ButtonText] forced-colors:data-disabled:outline-[GrayText]",
59
+ outline:
60
+ "forced-colors:border-[ButtonText] forced-colors:data-disabled:border-[GrayText]",
61
+ };
62
+
51
63
  /**
52
64
  * 工具栏:一排作用于同一个对象的小工具,收在一条带子里。整条只占一个 Tab 停靠点,
53
65
  * 方向键在各项之间走。里面放 `ToolbarButton`、`ToolbarToggle`、`ToolbarToggleGroup`、
@@ -98,6 +110,7 @@ export function Toolbar({
98
110
  // 放不下就换行,不出横向滚动条
99
111
  orientation === "vertical" ? "flex-col" : "flex-wrap items-center",
100
112
  barVariant[variant],
113
+ barForced[variant],
101
114
  className,
102
115
  )}
103
116
  />
@@ -135,6 +148,13 @@ const itemBase = [
135
148
  "data-popup-open:bg-surface-inverse data-popup-open:text-ink-inverse",
136
149
  "data-disabled:cursor-not-allowed data-disabled:bg-transparent data-disabled:text-ink-disabled",
137
150
  "data-disabled:data-pressed:bg-disabled data-disabled:data-pressed:text-on-disabled",
151
+ // 高对比模式下底色都被系统换掉,按下的和没按的就成了一个样子:按下的改用系统的"选中"色(禁用的是灰字色)。
152
+ // 这一个的颜色不让系统再改(不然它会在字后面垫一块页面色),焦点环跟着写明
153
+ "forced-colors:data-pressed:forced-color-adjust-none forced-colors:data-pressed:outline-[Highlight]",
154
+ "forced-colors:data-pressed:bg-[Highlight] forced-colors:data-pressed:text-[HighlightText]",
155
+ "forced-colors:data-disabled:data-pressed:bg-[GrayText] forced-colors:data-disabled:data-pressed:text-[Canvas]",
156
+ // 禁用的钮还走得到,所以不是真的 disabled:系统不会替它把字换灰,自己写明
157
+ "forced-colors:data-disabled:text-[GrayText]",
138
158
  // 焦点环压在旁边的钮上面
139
159
  "focus-visible:z-1",
140
160
  focusRing,
@@ -146,6 +166,8 @@ const itemIcon = [
146
166
  "group-data-pressed/item:text-accent-ink-inverse",
147
167
  "group-data-popup-open/item:text-accent-ink-inverse",
148
168
  "group-data-disabled/item:group-data-pressed/item:text-on-disabled",
169
+ "forced-colors:group-data-pressed/item:text-[HighlightText]",
170
+ "forced-colors:group-data-disabled/item:group-data-pressed/item:text-[Canvas]",
149
171
  ].join(" ");
150
172
 
151
173
  function useItemClass(iconOnly: boolean, className: string | undefined) {
@@ -380,6 +402,8 @@ export function ToolbarSeparator({
380
402
  {...props}
381
403
  className={cn(
382
404
  "shrink-0 self-center bg-line-strong",
405
+ // 高对比模式下这条线是底色画的,会不见:用控件的字色;所在的那一条(或那一组)禁用时跟着灰
406
+ "forced-colors:bg-[ButtonText] forced-colors:in-data-disabled:bg-[GrayText]",
383
407
  "data-[orientation=vertical]:mx-1 data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px",
384
408
  "data-[orientation=horizontal]:my-1 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-4",
385
409
  className,
@@ -11,6 +11,7 @@ import {
11
11
  import { createPortal } from "react-dom";
12
12
  import { usePortalScope } from "../../hooks/usePortalScope";
13
13
  import { cn } from "../../lib/cn";
14
+ import { enterFromSide } from "../../lib/motion";
14
15
 
15
16
  export type TooltipSide = "top" | "bottom" | "left" | "right";
16
17
  export type TooltipAlign = "start" | "center" | "end";
@@ -152,8 +153,11 @@ export function Tooltip({
152
153
  className={cn(
153
154
  // 透明的边线:高对比模式下背景色被系统覆盖,边线会自己显形
154
155
  "relative max-w-64 border border-transparent bg-surface-raised px-2.5 py-1.5 text-sm wrap-anywhere text-ink",
155
- "origin-(--transform-origin) transition-opacity duration-(--duration-fast) ease-standard",
156
+ // 进场从触发元素那一侧挪过来 4px,退场只淡出。小件:进出都是 200ms。
157
+ // data-instant(键盘聚焦、移到相邻的提示)时基元要它立刻出现,整个不过渡
158
+ "origin-(--transform-origin) transition-[opacity,translate] duration-(--duration-fast) ease-standard",
156
159
  "data-ending-style:opacity-0 data-instant:transition-none data-starting-style:opacity-0",
160
+ enterFromSide,
157
161
  className,
158
162
  )}
159
163
  >
@@ -16,6 +16,12 @@ export type ViewfinderProps = ComponentProps<"div"> & {
16
16
  size?: ViewfinderSize;
17
17
  /** 正中放一个小准星,标出焦点 */
18
18
  crosshair?: boolean;
19
+ /**
20
+ * 挂上的时候取景角落位:八段短线各自从角上伸出来,播一次。
21
+ * 默认不动——取景角平时是静态的装饰。要它晚一点再动(等外面那一层先淡入完),
22
+ * 在外层设 `--bracket-delay`
23
+ */
24
+ animate?: boolean;
19
25
  /**
20
26
  * 四个角上的读数:坐标、比例、版本、时间码。
21
27
  * 必须是真实的数据;没有就不放。
@@ -44,6 +50,7 @@ const readoutPosition: Record<keyof ViewfinderReadouts, string> = {
44
50
  export function Viewfinder({
45
51
  size = "sm",
46
52
  crosshair = false,
53
+ animate = false,
47
54
  readouts,
48
55
  className,
49
56
  children,
@@ -58,6 +65,8 @@ export function Viewfinder({
58
65
  decor,
59
66
  "corner-brackets absolute inset-3 [--bracket-color:currentColor] [--bracket-offset:0px] [--bracket-width:1.5px]",
60
67
  armClass[size],
68
+ animate &&
69
+ "after:animate-bracket-in after:[animation-delay:var(--bracket-delay,0s)]",
61
70
  )}
62
71
  />
63
72
  {crosshair && (
@@ -4,11 +4,17 @@
4
4
 
5
5
  | hook | 作用 |
6
6
  | --- | --- |
7
- | `useTheme` | 读写 `<html data-theme>`;支持 `light` / `dark` / `system`,选择记在 `localStorage` |
7
+ | `useTheme` | 读写 `<html data-theme>`;支持 `light` / `dark` / `system`,选择记在 `localStorage`。亮暗变了的时候放在视图过渡里换,整页交叉淡化一次(200ms,时长在 [theme.css](../styles/theme.css) 的 `data-theme-switching` 下);浏览器不支持、系统开了"减少动态效果"时直接切 |
8
8
  | `useControllableState` | 受控 / 非受控状态合并;值没变时不触发 `onChange` |
9
9
  | `useInView` | 元素第一次进入视口后返回 `true`,之后不再变回去——入场动画只播一次 |
10
+ | `useReducedMotion` | 系统开了"减少动态效果"时返回 `true`,设置变了跟着变。只给脚本驱动的动效用(滚动数字):CSS 的动画和过渡不用它 |
11
+ | `usePresence` | 退场后再卸载:`open` 变成假之后元素先留着,等它身上正在跑的过渡和动画走完才卸载;没有动效可等就当场卸载。另外告诉你这个元素是不是"后来才出现的"——一开始就在的不播进场。加载页的滑出在用。不对外导出 |
12
+ | `useAddedKeys` | 一串 key 里哪几个是后来加进来的:一开始就在的不算,删了又加回来的算。"新项淡入"只给这几个加入场的类(标签输入、文件上传)。不对外导出 |
13
+ | `useIndicator` | 选中指示的位置:量出容器里当前项相对容器的位置和尺寸,写成容器上的 CSS 变量,并标上有没有当前项(`data-indicator`)。换了一项才滑;第一次到位、同一项挪了地方或变了大小都直接到;量不到就不标,各项照旧自己画选中态。给分段选择、页内目录、胶囊页签的滑动用。不对外导出 |
14
+ | `useLingering` | 列表里被拿掉的项先留着:给它现在的那几个 key,返回"现在的 + 正在走的"(走的带 `leaving`,排在原来的位置上),等走的那一项身上的动效走完才拿掉——等法和 `usePresence` 是同一个(`afterMotion`),没有动效可等就当场拿掉。同时有加进来的不留。给标签输入、文件上传的列表用。不对外导出 |
15
+ | `useFlip` | 补位:一排里有一项走了(带着 `data-leaving`),其余的直接子元素从旧位置滑到新位置——每次渲染后记位置,少了一项的那一次把挪了地方的先用 `translate` 摆回旧位置、再撤掉,滑回来靠 CSS 的过渡。走的那一项被钉在原处;容器因此变矮了,高度也量着收过去。只有少了一项才补位;超过 50 项、量不到就不做。给标签输入用。不对外导出 |
10
16
  | `useOverflowing` | 容器里的内容是不是比容器宽、比容器高。给"只有真的能滚动时才是一个能聚焦的区域"用(表格、排期)。不对外导出 |
11
17
  | `useScrollPosition` | 盯着一个滚动容器(不传就是整个页面),由滚动位置算出一个值:滚动、窗口尺寸变了、内容晚到都会重新算,值没变不重新渲染。滚动提示和回到顶部共用。不对外导出 |
12
18
  | `usePortalScope` | 浮层挂到 `<body>` 下之后拿不到局部容器上的 `data-theme` / `data-choice`:返回 `anchorRef`(给触发元素)和 `portalRef`(给浮层的容器),容器挂载时把最近的开关抄过去。`invertTheme` 抄相反的主题(文字提示)。最近的是反转块(`data-theme="inverse"`)时,抄的是它实际算出来的亮或暗 |
13
19
 
14
- 还没有 `useReducedMotion`:目前的动效都是 CSS 动画与过渡,由 [theme.css](../styles/theme.css) 末尾的全局规则统一降级。等出现用 JS 驱动的动效时再加。
20
+ 动效几乎都是 CSS 的动画与过渡,由 [theme.css](../styles/theme.css) 末尾的全局规则统一降级,不用在组件里判断。那条规则管不到脚本:一帧一帧自己算的动效(现在只有滚动数字)要用 `useReducedMotion` 自己看;脚本里要的平滑滚动、视图过渡(页内目录、回到顶部、`useTheme`)也是各自当场看一眼系统的设置。
@@ -1,4 +1,5 @@
1
1
  export { useControllableState } from "./useControllableState";
2
2
  export { useInView } from "./useInView";
3
+ export { useReducedMotion } from "./useReducedMotion";
3
4
  export { useTheme, type Theme, type ThemePreference } from "./useTheme";
4
5
  export { usePortalScope } from "./usePortalScope";
@@ -0,0 +1,26 @@
1
+ import { useState } from "react";
2
+
3
+ const NONE: ReadonlySet<string> = new Set();
4
+
5
+ const same = (a: readonly string[], b: readonly string[]) =>
6
+ a.length === b.length && a.every((key, index) => key === b[index]);
7
+
8
+ /**
9
+ * 一串 key 里哪几个是后来加进来的。一开始就在的不算;删掉之后又加回来的算。
10
+ *
11
+ * 给"新项淡入"用:入场的类只加在这几个上。一开始就有的不该在载入时播一遍,
12
+ * 已经在的也不会因为别的项变了而重播——它一直带着那个类,动画只在挂上时播一次。
13
+ */
14
+ export function useAddedKeys(keys: readonly string[]): ReadonlySet<string> {
15
+ const [state, setState] = useState({ keys, added: NONE });
16
+ if (!same(state.keys, keys)) {
17
+ const before = new Set(state.keys);
18
+ setState({
19
+ keys,
20
+ added: new Set(
21
+ keys.filter((key) => !before.has(key) || state.added.has(key)),
22
+ ),
23
+ });
24
+ }
25
+ return state.added;
26
+ }