@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.
- package/NOTICE.md +2 -2
- package/README.md +103 -8
- package/dist/index.d.ts +69 -15
- package/dist/index.js +1102 -237
- 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 +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 +5 -2
- 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
|
@@ -16,10 +16,12 @@ import {
|
|
|
16
16
|
} from "react";
|
|
17
17
|
import { useControllableState } from "../../hooks/useControllableState";
|
|
18
18
|
import { useOverflowing } from "../../hooks/useOverflowing";
|
|
19
|
+
import { usePresence, type Presence } from "../../hooks/usePresence";
|
|
19
20
|
import { TriangleRight } from "../../icons/TriangleRight";
|
|
20
21
|
import { cn } from "../../lib/cn";
|
|
21
22
|
import { focusRing, focusRingInset } from "../../lib/focus-ring";
|
|
22
23
|
import { mergeRefs } from "../../lib/merge-refs";
|
|
24
|
+
import { collapse, collapseEnter, collapseInner } from "../../lib/motion";
|
|
23
25
|
|
|
24
26
|
export type TableHeaderVariant = "band" | "muted";
|
|
25
27
|
export type TableSize = "sm" | "md";
|
|
@@ -173,10 +175,11 @@ export type TableBodyProps = ComponentProps<"tbody">;
|
|
|
173
175
|
|
|
174
176
|
/*
|
|
175
177
|
* 每隔五行加重一条线。只数主行:展开的明细也是一个 <tr>,不能让它把数数错。
|
|
176
|
-
*
|
|
178
|
+
* 第五行后面跟着明细的时候(展开着,或者正在收起),它的下边线没有了,
|
|
179
|
+
* 加重的那条画在它的明细下面
|
|
177
180
|
*/
|
|
178
181
|
const ruledLines = [
|
|
179
|
-
"[&>tr:nth-child(5n_of_:not([data-detail])):not([data-
|
|
182
|
+
"[&>tr:nth-child(5n_of_:not([data-detail])):not(:has(+tr[data-detail]))>*]:border-line-strong",
|
|
180
183
|
"[&>tr:nth-child(5n_of_:not([data-detail]))+tr[data-detail]>*]:border-line-strong",
|
|
181
184
|
].join(" ");
|
|
182
185
|
|
|
@@ -241,6 +244,8 @@ export function TableRow({
|
|
|
241
244
|
const expandable =
|
|
242
245
|
detail !== undefined && detail !== null && detail !== false;
|
|
243
246
|
const open = expandable && expanded;
|
|
247
|
+
// 收起:明细先按高度收回去,收完再卸载
|
|
248
|
+
const detailPresence = usePresence<HTMLDivElement>(open);
|
|
244
249
|
|
|
245
250
|
const context = useMemo(
|
|
246
251
|
() =>
|
|
@@ -269,18 +274,19 @@ export function TableRow({
|
|
|
269
274
|
className={cn(
|
|
270
275
|
"group/row transition-colors duration-(--duration-fast) ease-standard",
|
|
271
276
|
selected ? rowSelected : rowIdle,
|
|
272
|
-
//
|
|
273
|
-
"[
|
|
277
|
+
// 后面跟着明细(展开着,或者正在收起):它们是一块,中间不画线
|
|
278
|
+
"[&:has(+tr[data-detail])>*]:border-b-transparent",
|
|
274
279
|
className,
|
|
275
280
|
)}
|
|
276
281
|
>
|
|
277
282
|
<RowContext value={context}>{children}</RowContext>
|
|
278
283
|
</tr>
|
|
279
|
-
{
|
|
284
|
+
{detailPresence.mounted && (
|
|
280
285
|
<DetailRow
|
|
281
286
|
id={detailId}
|
|
282
287
|
rowRef={rowRef}
|
|
283
288
|
estimate={countCells(children)}
|
|
289
|
+
presence={detailPresence}
|
|
284
290
|
>
|
|
285
291
|
{detail}
|
|
286
292
|
</DetailRow>
|
|
@@ -303,11 +309,13 @@ function DetailRow({
|
|
|
303
309
|
id,
|
|
304
310
|
rowRef,
|
|
305
311
|
estimate,
|
|
312
|
+
presence,
|
|
306
313
|
children,
|
|
307
314
|
}: {
|
|
308
315
|
id: string;
|
|
309
316
|
rowRef: RefObject<HTMLTableRowElement | null>;
|
|
310
317
|
estimate: number;
|
|
318
|
+
presence: Presence<HTMLDivElement>;
|
|
311
319
|
children: ReactNode;
|
|
312
320
|
}) {
|
|
313
321
|
const [measured, setMeasured] = useState<number | null>(null);
|
|
@@ -323,14 +331,30 @@ function DetailRow({
|
|
|
323
331
|
});
|
|
324
332
|
|
|
325
333
|
return (
|
|
326
|
-
|
|
334
|
+
// 正在收起的明细点不到、Tab 不进去
|
|
335
|
+
<tr id={id} data-detail="" inert={presence.leaving}>
|
|
327
336
|
<td
|
|
328
337
|
colSpan={measured ?? estimate}
|
|
329
338
|
className="border-b border-line bg-surface-sunken p-0 [tr:last-child>&]:border-b-0"
|
|
330
339
|
>
|
|
331
|
-
{/*
|
|
332
|
-
|
|
333
|
-
|
|
340
|
+
{/*
|
|
341
|
+
* 表格横向滚动时明细不跟着滚走:钉在容器的左缘,宽度是容器看得见的那一段。
|
|
342
|
+
* 收放也在这一层上——表格行的高度过渡不了,过渡的是单元格里的它
|
|
343
|
+
*/}
|
|
344
|
+
<div
|
|
345
|
+
ref={presence.ref}
|
|
346
|
+
data-leaving={presence.leaving ? "" : undefined}
|
|
347
|
+
className={cn(
|
|
348
|
+
"sticky left-0 w-[var(--table-viewport,100%)]",
|
|
349
|
+
collapse,
|
|
350
|
+
presence.entered && collapseEnter,
|
|
351
|
+
)}
|
|
352
|
+
>
|
|
353
|
+
<div className={collapseInner}>
|
|
354
|
+
<div className="px-4 py-3 text-sm whitespace-normal">
|
|
355
|
+
{children}
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
334
358
|
</div>
|
|
335
359
|
</td>
|
|
336
360
|
</tr>
|
|
@@ -3,14 +3,18 @@ import {
|
|
|
3
3
|
useContext,
|
|
4
4
|
useId,
|
|
5
5
|
useMemo,
|
|
6
|
+
useState,
|
|
6
7
|
type ComponentProps,
|
|
7
8
|
type KeyboardEvent,
|
|
8
9
|
type MouseEvent,
|
|
9
10
|
} from "react";
|
|
10
11
|
import { useControllableState } from "../../hooks/useControllableState";
|
|
12
|
+
import { useIndicator } from "../../hooks/useIndicator";
|
|
11
13
|
import { TriangleRight } from "../../icons/TriangleRight";
|
|
12
14
|
import { cn } from "../../lib/cn";
|
|
13
15
|
import { focusRing, focusRingInset } from "../../lib/focus-ring";
|
|
16
|
+
import { mergeRefs } from "../../lib/merge-refs";
|
|
17
|
+
import { fadeInFast, indicator, indicatorBox } from "../../lib/motion";
|
|
14
18
|
import { capsuleBase, capsuleSize, capsuleState } from "../chip/capsule-style";
|
|
15
19
|
|
|
16
20
|
export type TabsVariant = "block" | "capsule" | "wedge";
|
|
@@ -22,6 +26,8 @@ type TabsContextValue = {
|
|
|
22
26
|
baseId: string;
|
|
23
27
|
variant: TabsVariant;
|
|
24
28
|
size: TabsSize;
|
|
29
|
+
/** 切换过没有:一开始选中的那一块面板是静止的,切过来的才淡入 */
|
|
30
|
+
switched: boolean;
|
|
25
31
|
};
|
|
26
32
|
|
|
27
33
|
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
@@ -71,9 +77,13 @@ export function Tabs({
|
|
|
71
77
|
onChange: onValueChange,
|
|
72
78
|
});
|
|
73
79
|
|
|
80
|
+
const [initial] = useState(current);
|
|
81
|
+
const [switched, setSwitched] = useState(false);
|
|
82
|
+
if (!switched && current !== initial) setSwitched(true);
|
|
83
|
+
|
|
74
84
|
const context = useMemo(
|
|
75
|
-
() => ({ value: current, select, baseId, variant, size }),
|
|
76
|
-
[current, select, baseId, variant, size],
|
|
85
|
+
() => ({ value: current, select, baseId, variant, size, switched }),
|
|
86
|
+
[current, select, baseId, variant, size, switched],
|
|
77
87
|
);
|
|
78
88
|
|
|
79
89
|
return (
|
|
@@ -85,8 +95,12 @@ export function Tabs({
|
|
|
85
95
|
|
|
86
96
|
export type TabListProps = ComponentProps<"div">;
|
|
87
97
|
|
|
88
|
-
export function TabList({ className, onKeyDown, ...props }: TabListProps) {
|
|
98
|
+
export function TabList({ className, onKeyDown, ref, ...props }: TabListProps) {
|
|
89
99
|
const { variant } = useTabsContext("TabList");
|
|
100
|
+
// 胶囊:选中的底画在这一栏上,换一个时是同一块滑过去。另外两种的"让位"是实测的,不动
|
|
101
|
+
const listRef = useIndicator<HTMLDivElement>(
|
|
102
|
+
variant === "capsule" ? '[role="tab"][aria-selected="true"]' : null,
|
|
103
|
+
);
|
|
90
104
|
|
|
91
105
|
// 漫游 tabindex:方向键在页签之间移动焦点并直接切换
|
|
92
106
|
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
@@ -128,13 +142,24 @@ export function TabList({ className, onKeyDown, ...props }: TabListProps) {
|
|
|
128
142
|
return (
|
|
129
143
|
<div
|
|
130
144
|
{...props}
|
|
145
|
+
ref={mergeRefs(listRef, ref)}
|
|
131
146
|
role="tablist"
|
|
132
147
|
aria-orientation="horizontal"
|
|
133
148
|
onKeyDown={handleKeyDown}
|
|
134
149
|
className={cn(
|
|
135
150
|
// 页签很多时横向滚动,不换行
|
|
136
151
|
"flex overflow-x-auto [scrollbar-width:thin]",
|
|
137
|
-
variant === "capsule" &&
|
|
152
|
+
variant === "capsule" && [
|
|
153
|
+
"gap-2 p-1",
|
|
154
|
+
// 胶囊各有各的宽:底滑过去的时候宽度跟着变。它在滚动的内容里,跟着内容走
|
|
155
|
+
indicator,
|
|
156
|
+
indicatorBox,
|
|
157
|
+
// 墨底提到各个胶囊的轮廓线之上:滑过去的时候是一块干净的实心胶囊,
|
|
158
|
+
// 不被相邻胶囊的轮廓切开。胶囊里的字另提一层,留在它上面
|
|
159
|
+
"after:z-0 after:rounded-full after:bg-surface-inverse",
|
|
160
|
+
// 高对比模式下底色被系统换掉,这一块成了盖住选中胶囊轮廓的一片页面色:改用系统的"选中"色
|
|
161
|
+
"forced-colors:after:bg-[Highlight]",
|
|
162
|
+
],
|
|
138
163
|
// 楔形坐在一条墨线上:亮色页面上黄色对白底的明度差很小,靠这条线托住
|
|
139
164
|
variant === "wedge" && "border-b-2 border-ink",
|
|
140
165
|
className,
|
|
@@ -205,14 +230,21 @@ export function Tab({
|
|
|
205
230
|
focusRing,
|
|
206
231
|
capsuleSize[size],
|
|
207
232
|
selected
|
|
208
|
-
?
|
|
233
|
+
? // 这一栏量到了位置,底就由栏上滑动的那一块来画
|
|
234
|
+
[
|
|
235
|
+
capsuleState.selected,
|
|
236
|
+
"in-data-indicator:bg-transparent forced-colors:in-data-indicator:bg-transparent",
|
|
237
|
+
]
|
|
209
238
|
: disabled
|
|
210
239
|
? capsuleState.disabled
|
|
211
240
|
: capsuleState.rest,
|
|
212
241
|
className,
|
|
213
242
|
)}
|
|
214
243
|
>
|
|
215
|
-
{
|
|
244
|
+
{/* 字在滑动的墨底之上;间距跟按钮自己的走,里面照旧是一行居中的弹性排布 */}
|
|
245
|
+
<span className="relative z-1 inline-flex items-center gap-[inherit]">
|
|
246
|
+
{children}
|
|
247
|
+
</span>
|
|
216
248
|
</button>
|
|
217
249
|
);
|
|
218
250
|
}
|
|
@@ -229,7 +261,13 @@ export function Tab({
|
|
|
229
261
|
focusRingInset,
|
|
230
262
|
wedgeSize[size],
|
|
231
263
|
selected
|
|
232
|
-
?
|
|
264
|
+
? // 焦点环画在里面、压在黄楔上:普通的焦点色在暗色主题下也是黄的,看不见,
|
|
265
|
+
// 换成压得住黄底的颜色。再往里收 2px:这个颜色贴着边的话,在暗色页面上
|
|
266
|
+
// 和页面分不开,看上去只是黄楔小了一圈。
|
|
267
|
+
// 高对比模式下黄底被系统换掉:楔形改用系统的"选中"色,字用配它的字色;
|
|
268
|
+
// 这一个的颜色不让系统再改,不然它会在字后面垫一块页面色。
|
|
269
|
+
// 画在里面的焦点环也用那个字色,再往里收 2px:贴着边的话它和页面一个颜色
|
|
270
|
+
"text-on-action before:bg-action focus-visible:-outline-offset-4 focus-visible:outline-on-action forced-colors:forced-color-adjust-none forced-colors:text-[HighlightText] forced-colors:before:bg-[Highlight] forced-colors:focus-visible:-outline-offset-4 forced-colors:focus-visible:outline-[HighlightText]"
|
|
233
271
|
: disabled
|
|
234
272
|
? "cursor-not-allowed text-ink-disabled"
|
|
235
273
|
: "text-ink hover:before:bg-ink/5",
|
|
@@ -287,7 +325,10 @@ export type TabPanelProps = ComponentProps<"div"> & {
|
|
|
287
325
|
value: string;
|
|
288
326
|
};
|
|
289
327
|
|
|
290
|
-
/**
|
|
328
|
+
/**
|
|
329
|
+
* 未选中的面板保留一个隐藏的空容器,让页签的 `aria-controls` 始终有所指。
|
|
330
|
+
* 切过来的面板淡入;一开始就选中的那一块不动。
|
|
331
|
+
*/
|
|
291
332
|
export function TabPanel({
|
|
292
333
|
value,
|
|
293
334
|
className,
|
|
@@ -305,7 +346,12 @@ export function TabPanel({
|
|
|
305
346
|
aria-labelledby={toId(context.baseId, "tab", value)}
|
|
306
347
|
hidden={!selected}
|
|
307
348
|
tabIndex={0}
|
|
308
|
-
className={cn(
|
|
349
|
+
className={cn(
|
|
350
|
+
focusRing,
|
|
351
|
+
// 只淡入、不位移:页签本身已经有"让位"的动作
|
|
352
|
+
selected && context.switched && fadeInFast,
|
|
353
|
+
className,
|
|
354
|
+
)}
|
|
309
355
|
>
|
|
310
356
|
{selected ? children : null}
|
|
311
357
|
</div>
|
|
@@ -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
|
|
371
|
-
{
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
</
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
?
|
|
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 ?
|
|
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
|
-
|
|
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 && (
|
|
@@ -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
|
>
|