@endfield-ui/react 0.3.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +2 -2
- package/README.md +103 -8
- package/dist/index.d.ts +69 -15
- package/dist/index.js +1104 -239
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/README.md +12 -3
- 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/carousel/Carousel.tsx +2 -1
- package/src/components/checkbox/Checkbox.tsx +1 -0
- package/src/components/checkbox/choice-style.ts +9 -4
- 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/dash-indicator/DashIndicator.tsx +10 -3
- package/src/components/data-row/DataRow.tsx +2 -1
- package/src/components/date-picker/DateField.tsx +5 -1
- package/src/components/dropdown-menu/MenuCheck.tsx +4 -2
- package/src/components/dropdown-menu/menu-style.ts +13 -3
- package/src/components/empty-state/EmptyState.tsx +5 -0
- package/src/components/field/Field.tsx +39 -7
- package/src/components/file-upload/FileUpload.tsx +78 -19
- package/src/components/icon-button/IconButton.tsx +18 -5
- package/src/components/input/control-box.ts +23 -2
- package/src/components/input/value-chip.ts +3 -2
- package/src/components/loader/Loader.tsx +12 -41
- package/src/components/nav-menu/NavMenu.tsx +3 -2
- package/src/components/otp-input/OtpInput.tsx +8 -2
- 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 +21 -4
- 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 +18 -6
- 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/toc/Toc.tsx +89 -7
- package/src/components/toolbar/Toolbar.tsx +24 -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 -1
- package/src/lib/cn.ts +1 -0
- package/src/lib/inline-style.ts +12 -0
- package/src/lib/motion.ts +75 -0
- package/src/styles/theme.css +38 -2
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/hooks/README.md
CHANGED
|
@@ -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
|
-
|
|
20
|
+
动效几乎都是 CSS 的动画与过渡,由 [theme.css](../styles/theme.css) 末尾的全局规则统一降级,不用在组件里判断。那条规则管不到脚本:一帧一帧自己算的动效(现在只有滚动数字)要用 `useReducedMotion` 自己看;脚本里要的平滑滚动、视图过渡(页内目录、回到顶部、`useTheme`)也是各自当场看一眼系统的设置。
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
+
}
|