@endfield-ui/react 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +2 -2
- package/README.md +103 -8
- package/dist/index.d.ts +78 -17
- package/dist/index.js +1184 -264
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/README.md +11 -2
- package/src/components/alert/Alert.tsx +63 -1
- package/src/components/avatar/Avatar.tsx +5 -2
- package/src/components/bracket-title/BracketTitle.tsx +32 -3
- package/src/components/button/Button.tsx +37 -5
- package/src/components/calendar/Calendar.tsx +49 -5
- package/src/components/checkbox/Checkbox.tsx +1 -0
- package/src/components/checkbox/choice-style.ts +13 -5
- package/src/components/chip/capsule-style.ts +18 -3
- package/src/components/combobox/Combobox.tsx +10 -0
- package/src/components/completion-banner/CompletionBanner.tsx +30 -2
- package/src/components/corner-brackets/CornerBrackets.tsx +2 -1
- package/src/components/dash-indicator/DashIndicator.tsx +5 -2
- package/src/components/data-row/DataRow.tsx +2 -1
- package/src/components/date-picker/DateField.tsx +5 -1
- package/src/components/dialog/Dialog.tsx +4 -2
- package/src/components/dropdown-menu/MenuCheck.tsx +4 -2
- package/src/components/dropdown-menu/menu-style.ts +22 -5
- package/src/components/empty-state/EmptyState.tsx +5 -0
- package/src/components/field/Field.tsx +39 -7
- package/src/components/file-upload/FileUpload.tsx +79 -20
- package/src/components/icon-button/IconButton.tsx +18 -5
- package/src/components/image-viewer/ImageViewer.tsx +58 -18
- package/src/components/input/control-box.ts +23 -2
- package/src/components/input/value-chip.ts +3 -2
- package/src/components/item-slot/ItemSlot.tsx +2 -1
- package/src/components/loader/Loader.tsx +12 -41
- package/src/components/nav-menu/NavMenu.tsx +17 -3
- package/src/components/otp-input/OtpInput.tsx +13 -4
- package/src/components/page-header/PageHeader.tsx +39 -3
- package/src/components/pagination/Pagination.tsx +2 -0
- package/src/components/progress/Progress.tsx +10 -3
- package/src/components/progress/ProgressRing.tsx +19 -4
- package/src/components/radio/Radio.tsx +2 -0
- package/src/components/rolling-number/RollingNumber.tsx +125 -0
- package/src/components/rolling-number/index.ts +1 -0
- package/src/components/schedule/Schedule.tsx +5 -3
- package/src/components/segmented-control/SegmentedControl.tsx +25 -4
- package/src/components/skeleton/Skeleton.tsx +4 -1
- package/src/components/slider/Slider.tsx +26 -5
- package/src/components/sparkline/Sparkline.tsx +29 -5
- package/src/components/spinner/Spinner.tsx +6 -2
- package/src/components/stepper/Stepper.tsx +4 -0
- package/src/components/steps/Steps.tsx +30 -9
- package/src/components/switch/Switch.tsx +26 -2
- package/src/components/table/Table.tsx +33 -9
- package/src/components/tabs/Tabs.tsx +55 -9
- package/src/components/tag-input/TagInput.tsx +82 -43
- package/src/components/textarea/Textarea.tsx +2 -1
- package/src/components/timeline/Timeline.tsx +4 -3
- package/src/components/toast/Toast.tsx +18 -5
- package/src/components/toc/Toc.tsx +89 -7
- package/src/components/toolbar/Toolbar.tsx +24 -0
- package/src/components/tooltip/Tooltip.tsx +5 -1
- package/src/components/viewfinder/Viewfinder.tsx +9 -0
- package/src/hooks/README.md +8 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAddedKeys.ts +26 -0
- package/src/hooks/useFlip.ts +211 -0
- package/src/hooks/useIndicator.ts +135 -0
- package/src/hooks/useLingering.ts +122 -0
- package/src/hooks/usePresence.ts +100 -0
- package/src/hooks/useReducedMotion.ts +26 -0
- package/src/hooks/useTheme.ts +44 -4
- package/src/index.ts +1 -0
- package/src/lib/README.md +2 -0
- package/src/lib/cn.ts +3 -0
- package/src/lib/inline-style.ts +12 -0
- package/src/lib/motion.ts +90 -0
- package/src/styles/theme.css +81 -2
- package/src/styles/utilities.css +2 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
const QUERY = "(prefers-reduced-motion: reduce)";
|
|
4
|
+
|
|
5
|
+
function subscribe(notify: () => void) {
|
|
6
|
+
if (typeof window.matchMedia !== "function") return () => {};
|
|
7
|
+
const media = window.matchMedia(QUERY);
|
|
8
|
+
media.addEventListener("change", notify);
|
|
9
|
+
return () => media.removeEventListener("change", notify);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function read() {
|
|
13
|
+
return (
|
|
14
|
+
typeof window.matchMedia === "function" && window.matchMedia(QUERY).matches
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 系统开了"减少动态效果"时返回 true,设置变了会跟着变。
|
|
20
|
+
*
|
|
21
|
+
* CSS 的动画和过渡不用它:`theme.css` 末尾的全局规则已经统一降级了。
|
|
22
|
+
* 只有脚本驱动的动效(一帧一帧自己算的)才要看它——那条规则管不到脚本。
|
|
23
|
+
*/
|
|
24
|
+
export function useReducedMotion(): boolean {
|
|
25
|
+
return useSyncExternalStore(subscribe, read, () => false);
|
|
26
|
+
}
|
package/src/hooks/useTheme.ts
CHANGED
|
@@ -7,11 +7,14 @@ type Snapshot = { theme: ThemePreference; resolvedTheme: Theme };
|
|
|
7
7
|
|
|
8
8
|
const STORAGE_KEY = "ef-theme";
|
|
9
9
|
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
10
|
+
const REDUCE_QUERY = "(prefers-reduced-motion: reduce)";
|
|
10
11
|
|
|
11
12
|
const serverSnapshot: Snapshot = { theme: "light", resolvedTheme: "light" };
|
|
12
13
|
const listeners = new Set<() => void>();
|
|
13
14
|
let snapshot: Snapshot | null = null;
|
|
14
15
|
let stopWatchingSystem: (() => void) | null = null;
|
|
16
|
+
/** 正在走的那一次切换(视图过渡):它走完才撤掉 <html> 上的标记 */
|
|
17
|
+
let switching: unknown = null;
|
|
15
18
|
|
|
16
19
|
function isPreference(value: unknown): value is ThemePreference {
|
|
17
20
|
return value === "light" || value === "dark" || value === "system";
|
|
@@ -42,10 +45,45 @@ function getSnapshot(): Snapshot {
|
|
|
42
45
|
return snapshot;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* 换主题。亮暗真的变了、浏览器有视图过渡、系统没开"减少动态效果"时,放在视图过渡里换:
|
|
50
|
+
* 整页交叉淡化一次,而不是带换色过渡的慢慢过去、别的硬切,参差不齐。否则当场换。
|
|
51
|
+
*
|
|
52
|
+
* 走视图过渡的时候是晚一帧才换的——浏览器要先把旧的样子拍下来。
|
|
53
|
+
* 期间 <html> 上标着 data-theme-switching:淡化的时长挂在它下面(theme.css),
|
|
54
|
+
* 使用方自己的视图过渡不受影响。局部主题那几块新旧一个样,看不出变化。
|
|
55
|
+
*/
|
|
45
56
|
function commit(theme: ThemePreference) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
const next: Snapshot = { theme, resolvedTheme: resolve(theme) };
|
|
58
|
+
const element = document.documentElement;
|
|
59
|
+
const apply = () => {
|
|
60
|
+
snapshot = next;
|
|
61
|
+
element.dataset.theme = next.resolvedTheme;
|
|
62
|
+
for (const listener of listeners) listener();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
element.dataset.theme === next.resolvedTheme ||
|
|
67
|
+
typeof document.startViewTransition !== "function" ||
|
|
68
|
+
// 全局的降级规则管不到视图过渡的那几层:这里自己看
|
|
69
|
+
window.matchMedia?.(REDUCE_QUERY).matches
|
|
70
|
+
) {
|
|
71
|
+
apply();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
element.dataset.themeSwitching = "";
|
|
76
|
+
const transition = document.startViewTransition(apply);
|
|
77
|
+
switching = transition;
|
|
78
|
+
const done = () => {
|
|
79
|
+
// 被下一次切换打断了的:标记留给后来的那一次撤
|
|
80
|
+
if (switching !== transition) return;
|
|
81
|
+
switching = null;
|
|
82
|
+
delete element.dataset.themeSwitching;
|
|
83
|
+
};
|
|
84
|
+
transition.finished.then(done, done);
|
|
85
|
+
// 被打断时这个承诺是被拒绝的:不算错
|
|
86
|
+
transition.ready.catch(() => {});
|
|
49
87
|
}
|
|
50
88
|
|
|
51
89
|
function subscribe(listener: () => void) {
|
|
@@ -83,7 +121,8 @@ function setTheme(theme: ThemePreference) {
|
|
|
83
121
|
*
|
|
84
122
|
* - `theme`:用户的选择,可以是 `"system"`;
|
|
85
123
|
* - `resolvedTheme`:实际生效的 `"light"` 或 `"dark"`;
|
|
86
|
-
* - `setTheme`:切换并记到 `localStorage
|
|
124
|
+
* - `setTheme`:切换并记到 `localStorage`。亮暗变了的时候整页交叉淡化一次
|
|
125
|
+
* (浏览器支持视图过渡、系统没开"减少动态效果"时;否则直接切)。
|
|
87
126
|
*/
|
|
88
127
|
export function useTheme(): Snapshot & {
|
|
89
128
|
setTheme: (theme: ThemePreference) => void;
|
|
@@ -94,6 +133,7 @@ export function useTheme(): Snapshot & {
|
|
|
94
133
|
() => serverSnapshot,
|
|
95
134
|
);
|
|
96
135
|
|
|
136
|
+
// 挂上的时候把记着的主题同步到 <html>:这不是切换,直接写,不淡
|
|
97
137
|
useEffect(() => {
|
|
98
138
|
document.documentElement.dataset.theme = resolvedTheme;
|
|
99
139
|
}, [resolvedTheme]);
|
package/src/index.ts
CHANGED
|
@@ -51,6 +51,7 @@ export * from "./components/progress";
|
|
|
51
51
|
export * from "./components/radio";
|
|
52
52
|
export * from "./components/rec-indicator";
|
|
53
53
|
export * from "./components/registration-strip";
|
|
54
|
+
export * from "./components/rolling-number";
|
|
54
55
|
export * from "./components/schedule";
|
|
55
56
|
export * from "./components/scroll-area";
|
|
56
57
|
export * from "./components/scroll-hint";
|