@endfield-ui/react 0.3.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 (69) 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 +1102 -237
  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 +9 -4
  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/dash-indicator/DashIndicator.tsx +5 -2
  19. package/src/components/data-row/DataRow.tsx +2 -1
  20. package/src/components/date-picker/DateField.tsx +5 -1
  21. package/src/components/dropdown-menu/MenuCheck.tsx +4 -2
  22. package/src/components/dropdown-menu/menu-style.ts +13 -3
  23. package/src/components/empty-state/EmptyState.tsx +5 -0
  24. package/src/components/field/Field.tsx +39 -7
  25. package/src/components/file-upload/FileUpload.tsx +78 -19
  26. package/src/components/icon-button/IconButton.tsx +18 -5
  27. package/src/components/input/control-box.ts +23 -2
  28. package/src/components/input/value-chip.ts +3 -2
  29. package/src/components/loader/Loader.tsx +12 -41
  30. package/src/components/nav-menu/NavMenu.tsx +3 -2
  31. package/src/components/otp-input/OtpInput.tsx +8 -2
  32. package/src/components/page-header/PageHeader.tsx +39 -3
  33. package/src/components/pagination/Pagination.tsx +2 -0
  34. package/src/components/progress/Progress.tsx +10 -3
  35. package/src/components/progress/ProgressRing.tsx +19 -4
  36. package/src/components/radio/Radio.tsx +2 -0
  37. package/src/components/rolling-number/RollingNumber.tsx +125 -0
  38. package/src/components/rolling-number/index.ts +1 -0
  39. package/src/components/schedule/Schedule.tsx +5 -3
  40. package/src/components/segmented-control/SegmentedControl.tsx +25 -4
  41. package/src/components/skeleton/Skeleton.tsx +4 -1
  42. package/src/components/slider/Slider.tsx +21 -4
  43. package/src/components/sparkline/Sparkline.tsx +29 -5
  44. package/src/components/spinner/Spinner.tsx +6 -2
  45. package/src/components/stepper/Stepper.tsx +4 -0
  46. package/src/components/steps/Steps.tsx +18 -6
  47. package/src/components/switch/Switch.tsx +26 -2
  48. package/src/components/table/Table.tsx +33 -9
  49. package/src/components/tabs/Tabs.tsx +55 -9
  50. package/src/components/tag-input/TagInput.tsx +82 -43
  51. package/src/components/textarea/Textarea.tsx +2 -1
  52. package/src/components/timeline/Timeline.tsx +4 -3
  53. package/src/components/toc/Toc.tsx +89 -7
  54. package/src/components/toolbar/Toolbar.tsx +24 -0
  55. package/src/hooks/README.md +8 -2
  56. package/src/hooks/index.ts +1 -0
  57. package/src/hooks/useAddedKeys.ts +26 -0
  58. package/src/hooks/useFlip.ts +211 -0
  59. package/src/hooks/useIndicator.ts +135 -0
  60. package/src/hooks/useLingering.ts +122 -0
  61. package/src/hooks/usePresence.ts +100 -0
  62. package/src/hooks/useReducedMotion.ts +26 -0
  63. package/src/hooks/useTheme.ts +44 -4
  64. package/src/index.ts +1 -0
  65. package/src/lib/README.md +2 -1
  66. package/src/lib/cn.ts +1 -0
  67. package/src/lib/inline-style.ts +12 -0
  68. package/src/lib/motion.ts +75 -0
  69. package/src/styles/theme.css +38 -2
@@ -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,
@@ -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
+ }
@@ -0,0 +1,211 @@
1
+ import { useEffect, useLayoutEffect, useRef, type RefObject } from "react";
2
+ import { dropEmptyStyle } from "../lib/inline-style";
3
+ import { afterMotion } from "./usePresence";
4
+
5
+ /** 一排里超过这么多项就不补位:一项一项量、一项一项起过渡,多了不划算,也看不清谁去了哪 */
6
+ export const FLIP_LIMIT = 50;
7
+
8
+ /** 一项在容器里的排版位置:相对容器的内边距盒,不算它身上挂着的位移 */
9
+ type Place = { left: number; top: number; width: number; height: number };
10
+
11
+ /** 一个容器记着的事 */
12
+ type Memory = {
13
+ /** 上一次量的时候各项在哪 */
14
+ seen: Map<Element, Place>;
15
+ /** 上一次量的时候容器多高 */
16
+ height: number;
17
+ /** 钉过的(或者钉不住藏起来的)那几项 */
18
+ pinned: WeakSet<Element>;
19
+ /** 容器的高度正在收:取消"收完把写上去的撤掉"的那一次等待 */
20
+ settle: (() => void) | null;
21
+ };
22
+
23
+ const px = (value: number) => `${Math.round(value * 100) / 100}px`;
24
+
25
+ /** 这个元素眼下挂着的位移:补位滑到一半时不是 0 */
26
+ function flight(node: Element): [number, number] {
27
+ const value = getComputedStyle(node).translate;
28
+ if (!value || value === "none") return [0, 0];
29
+ const [x = 0, y = 0] = value
30
+ .split(" ")
31
+ .map((part) => Number.parseFloat(part) || 0);
32
+ return [x, y];
33
+ }
34
+
35
+ /** 把写上去的内联样式撤干净:原来没有 style 属性的,不留一个空的在上面 */
36
+ function unset(node: HTMLElement, names: readonly string[]) {
37
+ for (const name of names) node.style.removeProperty(name);
38
+ dropEmptyStyle(node);
39
+ }
40
+
41
+ const PIN = ["left", "top", "width", "height"] as const;
42
+ /** 往走的那一项上写过的全部:钉它的那四样,钉不住时的藏 */
43
+ const HELD = [...PIN, "display"] as const;
44
+
45
+ /**
46
+ * 量一遍、记下来。这一次有一项刚走,就补位:走的钉在原处,
47
+ * 其余挪了地方的先摆回旧位置(不过渡),再撤掉——撤的那一下滑回原位;
48
+ * 容器因此变矮了的话,高度也从原来的收到新的。
49
+ */
50
+ function arrange(node: HTMLElement, memory: Memory) {
51
+ const { seen, pinned } = memory;
52
+ const items = [...node.children] as HTMLElement[];
53
+ const leaving = (item: Element) => item.hasAttribute("data-leaving");
54
+ // 走到一半又回来了的(删了马上又加回来):往它上面写过的撤掉
55
+ for (const item of items) {
56
+ if (leaving(item) || !pinned.has(item)) continue;
57
+ pinned.delete(item);
58
+ unset(item, HELD);
59
+ }
60
+
61
+ const box = node.getBoundingClientRect();
62
+ // 量不到(服务端渲染、还没排版、藏着)或者项太多:不做,旧的位置也不留。
63
+ // 刚走的那一项这时候钉不住——它已经脱离排版,没有位置会跑到这一排的头上去,不留在画面上
64
+ if (
65
+ box.width === 0 ||
66
+ box.height === 0 ||
67
+ items.filter((item) => !leaving(item)).length > FLIP_LIMIT
68
+ ) {
69
+ seen.clear();
70
+ memory.height = 0;
71
+ for (const item of items) {
72
+ if (!leaving(item) || pinned.has(item)) continue;
73
+ pinned.add(item);
74
+ item.style.setProperty("display", "none");
75
+ }
76
+ return;
77
+ }
78
+ // 祖先带缩放时,量到的矩形是放大过的:折回排版的尺寸
79
+ const laid = Number.parseFloat(getComputedStyle(node).width);
80
+ const scale = laid > 0 ? box.width / laid : 1;
81
+
82
+ // 先全部量完再动手写:一边量一边写,每一项都要重排一遍
83
+ const gone: [HTMLElement, Place | undefined][] = [];
84
+ const moved: [HTMLElement, number, number][] = [];
85
+ const next: [Element, Place][] = [];
86
+ for (const item of items) {
87
+ const before = seen.get(item);
88
+ if (leaving(item)) {
89
+ if (!pinned.has(item)) gone.push([item, before]);
90
+ continue;
91
+ }
92
+ const rect = item.getBoundingClientRect();
93
+ const [x, y] = flight(item);
94
+ const place = {
95
+ left:
96
+ (rect.left - box.left) / scale - node.clientLeft + node.scrollLeft - x,
97
+ top: (rect.top - box.top) / scale - node.clientTop + node.scrollTop - y,
98
+ width: rect.width / scale,
99
+ height: rect.height / scale,
100
+ };
101
+ next.push([item, place]);
102
+ if (!before) continue;
103
+ // 眼前的位置(旧的排版位置,加上还没走完的位移)离新的排版位置有多远
104
+ const dx = before.left + x - place.left;
105
+ const dy = before.top + y - place.top;
106
+ if (Math.abs(dx) >= 0.5 || Math.abs(dy) >= 0.5) moved.push([item, dx, dy]);
107
+ }
108
+ seen.clear();
109
+ for (const [item, place] of next) seen.set(item, place);
110
+
111
+ // 容器眼前有多高。高度正收到一半时,量到的就是收到一半的那个数
112
+ const tall = box.height / scale;
113
+ const shrinking = memory.settle !== null;
114
+ const from = shrinking ? tall : memory.height;
115
+ if (!shrinking) memory.height = tall;
116
+
117
+ // 只在有一项刚走的那一次补位。加了一项、容器宽了、字体到了,别的项都是直接到位的
118
+ if (gone.length === 0) return;
119
+
120
+ for (const [item, place] of gone) {
121
+ pinned.add(item);
122
+ if (place) {
123
+ PIN.forEach((name) => item.style.setProperty(name, px(place[name])));
124
+ } else {
125
+ // 没记过它的位置(刚加进来就走了):钉不住,不留在画面上
126
+ item.style.setProperty("display", "none");
127
+ }
128
+ }
129
+
130
+ // 少了一行,容器就矮了:高度也跟着收,不然它当场缩上去,还在滑的那几项会露在外面,
131
+ // 后面的内容也跟着跳一下。量着收——先撑在眼前的高度上,再放到新的高度
132
+ memory.settle?.();
133
+ memory.settle = null;
134
+ node.style.setProperty("transition", "none");
135
+ node.style.removeProperty("min-height");
136
+ const to = node.getBoundingClientRect().height / scale;
137
+ memory.height = to;
138
+ const shrink = from - to >= 0.5;
139
+ if (shrink) node.style.setProperty("min-height", px(from));
140
+
141
+ for (const [item, dx, dy] of moved) {
142
+ item.style.setProperty("transition", "none");
143
+ item.style.setProperty("translate", `${px(dx)} ${px(dy)}`);
144
+ }
145
+ // 让"还在旧位置上"这一下落定,再撤掉:撤的那一下由各项自己的过渡滑回原位
146
+ node.getBoundingClientRect();
147
+ for (const [item] of moved) unset(item, ["transition", "translate"]);
148
+ node.style.removeProperty("transition");
149
+ if (shrink) {
150
+ node.style.setProperty("min-height", px(to));
151
+ // 收完就把写上去的撤掉:留着的话,下一次内容少了它不会跟着矮
152
+ memory.settle = afterMotion(node, () => {
153
+ memory.settle = null;
154
+ unset(node, ["min-height"]);
155
+ });
156
+ } else {
157
+ dropEmptyStyle(node);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * 补位:容器里有一项走了,其余的直接子元素从旧位置滑到新位置,不是瞬移过去。
163
+ * 返回的 `ref` 给容器。
164
+ *
165
+ * - 走的那一项要带 `data-leaving`、并且脱离排版(`lib/motion.ts` 的 `flipLeaving`):
166
+ * 钩子把它钉在原来的位置上,它在那里把退场走完;容器要是定位的参照(`flipBox`)。
167
+ * - 其余各项要给 `translate` 加上过渡:钩子只负责把它们摆回旧位置再放手,
168
+ * 滑回来靠的是 CSS——所以系统开了"减少动态效果"时是直接到位的。
169
+ * - 容器因此变矮了(少了一行),它的高度也从原来的收到新的(量着收,过渡同样在
170
+ * `flipBox` 里);收完撤掉,不留在容器上。
171
+ * - 各项只动 `translate`。正滑到一半又少一项,从眼前的位置接着走。
172
+ * - 只有"少了一项"才补位;加一项、容器尺寸变了,各项直接到位。
173
+ * - 超过 `FLIP_LIMIT` 项、量不到位置时不做:走的那一项也不留,照旧直接消失。
174
+ */
175
+ export function useFlip<
176
+ T extends HTMLElement = HTMLElement,
177
+ >(): RefObject<T | null> {
178
+ const ref = useRef<T>(null);
179
+ const memory = useRef<Memory>({
180
+ seen: new Map(),
181
+ height: 0,
182
+ pinned: new WeakSet(),
183
+ settle: null,
184
+ });
185
+
186
+ // 每次渲染后量一遍:哪一次渲染会少一项事先不知道,旧位置得一直是新的
187
+ useLayoutEffect(() => {
188
+ const node = ref.current;
189
+ if (node) arrange(node, memory.current);
190
+ });
191
+
192
+ // 容器的尺寸变了(窗口窄了、换行变了)没有渲染:重记一遍位置。
193
+ // 这时候没有刚走的项,所以不会补位
194
+ useLayoutEffect(() => {
195
+ const node = ref.current;
196
+ if (!node || typeof ResizeObserver === "undefined") return;
197
+ const observer = new ResizeObserver(() => arrange(node, memory.current));
198
+ observer.observe(node);
199
+ return () => observer.disconnect();
200
+ }, []);
201
+
202
+ useEffect(() => {
203
+ const held = memory.current;
204
+ return () => {
205
+ held.settle?.();
206
+ held.settle = null;
207
+ };
208
+ }, []);
209
+
210
+ return ref;
211
+ }
@@ -0,0 +1,135 @@
1
+ import { useLayoutEffect, useRef, type RefObject } from "react";
2
+
3
+ const VARS = [
4
+ "--indicator-x",
5
+ "--indicator-y",
6
+ "--indicator-w",
7
+ "--indicator-h",
8
+ ] as const;
9
+
10
+ function clear(node: HTMLElement) {
11
+ if (node.dataset.indicator === undefined) return;
12
+ delete node.dataset.indicator;
13
+ for (const name of VARS) node.style.removeProperty(name);
14
+ }
15
+
16
+ /** 量一次、写一次。`last` 记着上一次指的是哪一项:换了才滑 */
17
+ function place(
18
+ node: HTMLElement,
19
+ selector: string,
20
+ last: { current: Element | null },
21
+ ) {
22
+ const box = node.getBoundingClientRect();
23
+ // 量不到(还没排版,或者藏着):什么都不标,各项照旧自己画
24
+ if (box.width === 0 || box.height === 0) {
25
+ clear(node);
26
+ last.current = null;
27
+ return;
28
+ }
29
+
30
+ const item = node.querySelector(selector);
31
+ const rect = item?.getBoundingClientRect();
32
+ if (!item || !rect || rect.width === 0 || rect.height === 0) {
33
+ // 一项都没有:指示留在原地淡出
34
+ node.dataset.indicator = "off";
35
+ last.current = null;
36
+ return;
37
+ }
38
+
39
+ // 祖先带缩放时矩形是缩放之后的,变量要的是缩放之前的
40
+ const laid = Number.parseFloat(getComputedStyle(node).width);
41
+ const scale = laid > 0 ? box.width / laid : 1;
42
+ const values = [
43
+ (rect.left - box.left) / scale - node.clientLeft + node.scrollLeft,
44
+ (rect.top - box.top) / scale - node.clientTop + node.scrollTop,
45
+ rect.width / scale,
46
+ rect.height / scale,
47
+ ].map((value) => `${Math.round(value * 100) / 100}px`);
48
+
49
+ const slide =
50
+ node.dataset.indicator === "on" &&
51
+ last.current !== null &&
52
+ last.current !== item;
53
+ last.current = item;
54
+
55
+ if (
56
+ VARS.some((name, at) => node.style.getPropertyValue(name) !== values[at])
57
+ ) {
58
+ // 第一次到位、从没有到有、同一项挪了地方或变了大小:直接到,不过渡
59
+ if (!slide) node.dataset.indicatorInstant = "";
60
+ VARS.forEach((name, at) => node.style.setProperty(name, values[at]!));
61
+ if (!slide) {
62
+ // 先让这一步落定再撤标记:不然浏览器看到的仍然是"变了",照样过渡
63
+ node.getBoundingClientRect();
64
+ delete node.dataset.indicatorInstant;
65
+ }
66
+ }
67
+ node.dataset.indicator = "on";
68
+ }
69
+
70
+ /**
71
+ * 选中指示的位置:量出容器里"当前项"相对容器的位置和尺寸,写成容器上的 CSS 变量
72
+ * (`--indicator-x` / `-y` / `-w` / `-h`),并在容器上标 `data-indicator`——`on` 是有当前项,
73
+ * `off` 是没有。指示本身由用的地方画在容器的 `::after` 上(`lib/motion.ts` 的 `indicator`),
74
+ * 换了一项时由 CSS 的过渡滑过去。
75
+ *
76
+ * 只有"换了一项"才滑;第一次到位、同一项挪了地方或变了大小(容器宽了、字体到了、
77
+ * 前面多了一项)都直接到位。量不到(服务端、还没排版、藏着)时什么都不标,
78
+ * 各项照旧自己画选中态——不会有哪一帧没有选中态。
79
+ *
80
+ * `current` 是当前项在容器里的选择器;传 `null` 是不用。
81
+ */
82
+ export function useIndicator<T extends HTMLElement = HTMLElement>(
83
+ current: string | null,
84
+ ): RefObject<T | null> {
85
+ const ref = useRef<T>(null);
86
+ const last = useRef<Element | null>(null);
87
+ const observer = useRef<ResizeObserver | null>(null);
88
+ const watched = useRef<Element[]>([]);
89
+
90
+ // 每次渲染后量一次:选中的换了、项多了少了,都落在这里
91
+ useLayoutEffect(() => {
92
+ const node = ref.current;
93
+ if (!node) return;
94
+ if (current === null) {
95
+ clear(node);
96
+ last.current = null;
97
+ return;
98
+ }
99
+ place(node, current, last);
100
+
101
+ // 盯着的是容器和它的每一个直接子元素:哪一个变了大小,当前项都可能跟着挪
102
+ const watching = observer.current;
103
+ if (!watching) return;
104
+ const next = [node, ...node.children];
105
+ const before = watched.current;
106
+ if (
107
+ next.length === before.length &&
108
+ next.every((element, at) => element === before[at])
109
+ ) {
110
+ return;
111
+ }
112
+ watching.disconnect();
113
+ for (const element of next) watching.observe(element);
114
+ watched.current = next;
115
+ });
116
+
117
+ useLayoutEffect(() => {
118
+ const node = ref.current;
119
+ if (!node || current === null || typeof ResizeObserver === "undefined") {
120
+ return;
121
+ }
122
+ const watching = new ResizeObserver(() => place(node, current, last));
123
+ const targets = [node, ...node.children];
124
+ for (const element of targets) watching.observe(element);
125
+ observer.current = watching;
126
+ watched.current = targets;
127
+ return () => {
128
+ watching.disconnect();
129
+ observer.current = null;
130
+ watched.current = [];
131
+ };
132
+ }, [current]);
133
+
134
+ return ref;
135
+ }
@@ -0,0 +1,122 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type RefObject,
8
+ } from "react";
9
+ import { afterMotion } from "./usePresence";
10
+
11
+ export type LingeringEntry = {
12
+ key: string;
13
+ /** 已经从列表里拿掉了,留着把退场走完 */
14
+ leaving: boolean;
15
+ };
16
+
17
+ const same = (a: readonly string[], b: readonly string[]) =>
18
+ a.length === b.length && a.every((key, index) => key === b[index]);
19
+
20
+ /**
21
+ * 新的一份:现在的那几项按现在的顺序,走着的插回它原来挨着的那一项后面。
22
+ * `keep` 是这一次新走的要不要留。
23
+ */
24
+ function merge(
25
+ before: readonly LingeringEntry[],
26
+ keys: readonly string[],
27
+ keep: boolean,
28
+ ): LingeringEntry[] {
29
+ const now = new Set(keys);
30
+ const next: LingeringEntry[] = keys.map((key) => ({ key, leaving: false }));
31
+ // 顺着旧的那一份走一遍:anchor 是眼下最后一个还在新的一份里的
32
+ let anchor: string | null = null;
33
+ for (const entry of before) {
34
+ if (now.has(entry.key)) {
35
+ anchor = entry.key;
36
+ continue;
37
+ }
38
+ // 早先就在走的接着走;这一次才走的看 keep
39
+ if (!entry.leaving && !keep) continue;
40
+ const at =
41
+ anchor === null ? -1 : next.findIndex((item) => item.key === anchor);
42
+ next.splice(at + 1, 0, { key: entry.key, leaving: true });
43
+ anchor = entry.key;
44
+ }
45
+ return next;
46
+ }
47
+
48
+ /**
49
+ * 列表里被拿掉的项先留着:`keys` 里少了一项,它不当场消失,而是带着 `leaving`
50
+ * 留在原来的位置上,等它身上的退场走完才真的拿掉(等法和 `usePresence` 是同一套:
51
+ * 看元素上正在跑的动效,一个都没有就当场拿掉——测试环境里东西删了就是删了)。
52
+ *
53
+ * 用法:`entries` 按顺序渲染成 `ref` 那个容器的直接子元素,走着的那几项带上
54
+ * `data-leaving`(退场的样式挂在它上面),并且对键盘和读屏隐藏。钩子按这个标记找元素。
55
+ *
56
+ * - 这一次变化里同时有加进来的(整批换掉、"只收一个"换了一个):新走的不留,照旧直接换。
57
+ * - 走到一半又加回来:它不再是走着的,留在页面上的还是同一个元素。
58
+ * - `enabled` 为假时不留新走的(项太多的时候)。
59
+ */
60
+ export function useLingering<T extends Element = HTMLElement>(
61
+ keys: readonly string[],
62
+ enabled = true,
63
+ ): { ref: RefObject<T | null>; entries: readonly LingeringEntry[] } {
64
+ const ref = useRef<T>(null);
65
+ const [state, setState] = useState(() => ({
66
+ keys,
67
+ entries: merge([], keys, false),
68
+ }));
69
+ let { entries } = state;
70
+ if (!same(state.keys, keys)) {
71
+ const known = new Set(state.keys);
72
+ const grew = keys.some((key) => !known.has(key));
73
+ entries = merge(state.entries, keys, enabled && !grew);
74
+ setState({ keys, entries });
75
+ }
76
+
77
+ const drop = useCallback((key: string) => {
78
+ setState((current) => ({
79
+ keys: current.keys,
80
+ entries: current.entries.filter(
81
+ (entry) => !(entry.leaving && entry.key === key),
82
+ ),
83
+ }));
84
+ }, []);
85
+
86
+ // 每一个走着的等一次:它身上的退场走完就拿掉
87
+ const waits = useRef(new Map<string, () => void>());
88
+ useLayoutEffect(() => {
89
+ const pending = waits.current;
90
+ const leaving = entries
91
+ .filter((entry) => entry.leaving)
92
+ .map((entry) => entry.key);
93
+ // 不再是走着的了(走完了,或者又加回来了):不等了
94
+ for (const [key, cancel] of pending) {
95
+ if (leaving.includes(key)) continue;
96
+ cancel();
97
+ pending.delete(key);
98
+ }
99
+ if (leaving.length === 0) return;
100
+ // 走着的那几项在页面上的顺序,就是它们在 entries 里的顺序
101
+ const nodes = [...(ref.current?.children ?? [])].filter((node) =>
102
+ node.hasAttribute("data-leaving"),
103
+ );
104
+ leaving.forEach((key, index) => {
105
+ if (pending.has(key)) return;
106
+ pending.set(
107
+ key,
108
+ afterMotion(nodes[index] ?? null, () => drop(key)),
109
+ );
110
+ });
111
+ }, [entries, drop]);
112
+
113
+ useEffect(() => {
114
+ const pending = waits.current;
115
+ return () => {
116
+ for (const cancel of pending.values()) cancel();
117
+ pending.clear();
118
+ };
119
+ }, []);
120
+
121
+ return { ref, entries };
122
+ }
@@ -0,0 +1,100 @@
1
+ import { useLayoutEffect, useRef, useState, type RefObject } from "react";
2
+
3
+ type Options<T extends Element> = {
4
+ /**
5
+ * 开始退场、还没去看有没有动效在跑之前调。退场的起点要用脚本摆的在这里摆
6
+ * (提示条量自己的高度)。返回的函数在退场被打断(中途又打开)时把摆上去的撤掉
7
+ */
8
+ onLeave?: (node: T) => void | (() => void);
9
+ /** 退完、已经卸载之后 */
10
+ onExited?: () => void;
11
+ };
12
+
13
+ export type Presence<T extends Element> = {
14
+ /** 给会动的那个元素:退场时等的是它身上的过渡和动画 */
15
+ ref: RefObject<T | null>;
16
+ /** 该不该渲染 */
17
+ mounted: boolean;
18
+ /** 正在退场:`open` 已经是假的了,元素还留着 */
19
+ leaving: boolean;
20
+ /** 是后来才出现的。一开始就在的是 `false`——进场的起点只给后来出现的 */
21
+ entered: boolean;
22
+ };
23
+
24
+ /** 循环的动画(加载指示那一类)等不到头,不等 */
25
+ function ends(animation: Animation) {
26
+ return animation.effect?.getComputedTiming().iterations !== Infinity;
27
+ }
28
+
29
+ /**
30
+ * 等这个元素上**正在跑的**、有尽头的过渡和动画走完,再调 `done`;一个都没有
31
+ * (没有写退场的样式、测试环境里没有动效)就当场调。返回的函数取消这一次等待。
32
+ */
33
+ export function afterMotion(
34
+ node: Element | null,
35
+ done: () => void,
36
+ ): () => void {
37
+ let live = true;
38
+ const finish = () => {
39
+ if (live) done();
40
+ };
41
+ // 读这一下,浏览器会先把这次的样式变化算完:刚起的过渡已经在里面了。
42
+ // `finished` 要现在就拿住——过渡被取消之后再去拿,拿到的是一个永远不落定的新承诺
43
+ const running =
44
+ typeof node?.getAnimations === "function"
45
+ ? node.getAnimations().filter(ends)
46
+ : [];
47
+ if (running.length === 0) finish();
48
+ else {
49
+ void Promise.allSettled(
50
+ running.map((animation) => animation.finished),
51
+ ).then(finish);
52
+ }
53
+ return () => {
54
+ live = false;
55
+ };
56
+ }
57
+
58
+ /**
59
+ * 退场后再卸载:`open` 变成假之后元素先留着,等它身上的动效走完才卸载。
60
+ *
61
+ * 等的是这个元素上**正在跑的**过渡和动画,不另抄一份时长;一个都没有
62
+ * (没有写退场的样式、测试环境里没有动效)就当场卸载。退场途中又打开:不卸载,
63
+ * 过渡自己折回去。
64
+ */
65
+ export function usePresence<T extends Element = HTMLElement>(
66
+ open: boolean,
67
+ { onLeave, onExited }: Options<T> = {},
68
+ ): Presence<T> {
69
+ const ref = useRef<T>(null);
70
+ const [mounted, setMounted] = useState(open);
71
+ const [entered, setEntered] = useState(false);
72
+ // 打开:立刻挂上
73
+ if (open && !mounted) {
74
+ setMounted(true);
75
+ setEntered(true);
76
+ }
77
+ const leaving = !open && mounted;
78
+
79
+ const handlers = useRef({ onLeave, onExited });
80
+ useLayoutEffect(() => {
81
+ handlers.current = { onLeave, onExited };
82
+ });
83
+
84
+ useLayoutEffect(() => {
85
+ if (!leaving) return;
86
+ const node = ref.current;
87
+ const undo = node ? handlers.current.onLeave?.(node) : undefined;
88
+ const cancel = afterMotion(node, () => {
89
+ setMounted(false);
90
+ handlers.current.onExited?.();
91
+ });
92
+
93
+ return () => {
94
+ cancel();
95
+ undo?.();
96
+ };
97
+ }, [leaving]);
98
+
99
+ return { ref, mounted, leaving, entered };
100
+ }