@endfield-ui/react 0.1.0 → 0.3.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/LICENSE +21 -21
- package/README.md +55 -5
- package/dist/index.d.ts +213 -4
- package/dist/index.js +688 -109
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/README.md +8 -1
- package/src/components/avatar/Avatar.tsx +3 -0
- package/src/components/carousel/Carousel.tsx +7 -47
- package/src/components/carousel/snap-track.ts +74 -0
- package/src/components/checkbox/choice-style.ts +5 -2
- package/src/components/corner-brackets/CornerBrackets.tsx +2 -1
- package/src/components/dialog/Dialog.tsx +4 -2
- package/src/components/dropdown-menu/menu-style.ts +9 -2
- package/src/components/file-upload/FileUpload.tsx +1 -1
- package/src/components/hover-card/HoverCard.tsx +101 -0
- package/src/components/hover-card/index.ts +6 -0
- package/src/components/image-viewer/ImageViewer.tsx +467 -0
- package/src/components/image-viewer/index.ts +7 -0
- package/src/components/item-slot/ItemSlot.tsx +3 -1
- package/src/components/nav-menu/NavMenu.tsx +14 -1
- package/src/components/otp-input/OtpInput.tsx +214 -0
- package/src/components/otp-input/index.ts +7 -0
- package/src/components/page-header/PageHeader.tsx +182 -0
- package/src/components/page-header/index.ts +6 -0
- package/src/components/popover/Popover.tsx +4 -12
- package/src/components/popover/panel-style.ts +15 -0
- package/src/components/scroll-area/ScrollArea.tsx +137 -0
- package/src/components/scroll-area/index.ts +5 -0
- package/src/components/slider/Slider.tsx +6 -2
- package/src/components/steps/Steps.tsx +12 -3
- package/src/components/table/Table.tsx +1 -0
- package/src/components/tag-input/TagInput.tsx +1 -0
- package/src/components/toast/Toast.tsx +18 -5
- package/src/components/toc/Toc.tsx +1 -0
- package/src/components/tooltip/Tooltip.tsx +5 -1
- package/src/components/viewfinder/Viewfinder.tsx +9 -0
- package/src/index.ts +5 -0
- package/src/lib/README.md +1 -0
- package/src/lib/cn.ts +2 -0
- package/src/lib/motion.ts +15 -0
- package/src/styles/theme.css +43 -0
- package/src/styles/utilities.css +2 -1
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
|
|
2
|
+
import {
|
|
3
|
+
Children,
|
|
4
|
+
createContext,
|
|
5
|
+
isValidElement,
|
|
6
|
+
useContext,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ComponentProps,
|
|
10
|
+
type KeyboardEvent,
|
|
11
|
+
type ReactElement,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
type RefObject,
|
|
14
|
+
} from "react";
|
|
15
|
+
import { useControllableState } from "../../hooks/useControllableState";
|
|
16
|
+
import { ChevronLeft } from "../../icons/ChevronLeft";
|
|
17
|
+
import { ChevronRight } from "../../icons/ChevronRight";
|
|
18
|
+
import { Close } from "../../icons/Close";
|
|
19
|
+
import { cn } from "../../lib/cn";
|
|
20
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
21
|
+
import { useSnapTrack } from "../carousel/snap-track";
|
|
22
|
+
import { overlayClose, overlayHeader } from "../dialog/overlay-style";
|
|
23
|
+
import { IconButton } from "../icon-button/IconButton";
|
|
24
|
+
import { Viewfinder } from "../viewfinder/Viewfinder";
|
|
25
|
+
|
|
26
|
+
export type ImageViewerRatio = "16/9" | "4/3" | "1/1" | "3/4";
|
|
27
|
+
|
|
28
|
+
const ratioClass: Record<ImageViewerRatio, string> = {
|
|
29
|
+
"16/9": "aspect-video",
|
|
30
|
+
"4/3": "aspect-4/3",
|
|
31
|
+
"1/1": "aspect-square",
|
|
32
|
+
"3/4": "aspect-3/4",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ItemLike = {
|
|
36
|
+
title?: ReactNode;
|
|
37
|
+
description?: ReactNode;
|
|
38
|
+
children?: ReactNode;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const ItemContext = createContext<{
|
|
42
|
+
position: number;
|
|
43
|
+
count: number;
|
|
44
|
+
ratio: ImageViewerRatio;
|
|
45
|
+
viewLabel: string;
|
|
46
|
+
open: (position: number) => void;
|
|
47
|
+
register: (position: number, node: HTMLButtonElement | null) => void;
|
|
48
|
+
} | null>(null);
|
|
49
|
+
|
|
50
|
+
export type ImageViewerProps = Omit<ComponentProps<"ul">, "aria-label"> & {
|
|
51
|
+
/** 这组图是什么,比如"现场照片"。缩略图的列表和大图层都用它命名 */
|
|
52
|
+
"aria-label": string;
|
|
53
|
+
/** 大图层开没开 */
|
|
54
|
+
open?: boolean;
|
|
55
|
+
onOpenChange?: (open: boolean) => void;
|
|
56
|
+
/** 大图层现在是第几张,从 0 起 */
|
|
57
|
+
index?: number;
|
|
58
|
+
defaultIndex?: number;
|
|
59
|
+
onIndexChange?: (index: number) => void;
|
|
60
|
+
/** 到头之后绕回另一头。默认到头时翻页钮禁用 */
|
|
61
|
+
loop?: boolean;
|
|
62
|
+
/** 缩略图的宽高比,默认 `4/3`。大图不裁切,按原比例整张显示 */
|
|
63
|
+
ratio?: ImageViewerRatio;
|
|
64
|
+
/** 缩略图按钮名称的开头,默认"查看大图" */
|
|
65
|
+
viewLabel?: string;
|
|
66
|
+
previousLabel?: string;
|
|
67
|
+
nextLabel?: string;
|
|
68
|
+
closeLabel?: string;
|
|
69
|
+
/** 直接放 `ImageViewerItem` */
|
|
70
|
+
children: ReactNode;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 图片查看:一组缩略图,点一张放大到整屏看,前后翻。
|
|
75
|
+
* 大图那一层在亮暗两个主题下都是深色;翻页用的是媒体轮播的那条轨道,触屏可以左右滑。
|
|
76
|
+
*
|
|
77
|
+
* `className` 和其余属性给缩略图的列表。
|
|
78
|
+
*/
|
|
79
|
+
export function ImageViewer({
|
|
80
|
+
"aria-label": label,
|
|
81
|
+
open: openProp,
|
|
82
|
+
onOpenChange,
|
|
83
|
+
index: indexProp,
|
|
84
|
+
defaultIndex = 0,
|
|
85
|
+
onIndexChange,
|
|
86
|
+
loop = false,
|
|
87
|
+
ratio = "4/3",
|
|
88
|
+
viewLabel = "查看大图",
|
|
89
|
+
previousLabel = "上一张",
|
|
90
|
+
nextLabel = "下一张",
|
|
91
|
+
closeLabel = "关闭",
|
|
92
|
+
className,
|
|
93
|
+
children,
|
|
94
|
+
...props
|
|
95
|
+
}: ImageViewerProps) {
|
|
96
|
+
// 标题、说明和大图都从子元素上读,所以子元素必须直接是 ImageViewerItem
|
|
97
|
+
const items = Children.toArray(children).filter((child) =>
|
|
98
|
+
isValidElement<ItemLike>(child),
|
|
99
|
+
);
|
|
100
|
+
const count = items.length;
|
|
101
|
+
const last = Math.max(0, count - 1);
|
|
102
|
+
|
|
103
|
+
const [open, setOpen] = useControllableState({
|
|
104
|
+
value: openProp,
|
|
105
|
+
defaultValue: false,
|
|
106
|
+
onChange: onOpenChange,
|
|
107
|
+
});
|
|
108
|
+
const [rawIndex, setIndex] = useControllableState({
|
|
109
|
+
value: indexProp,
|
|
110
|
+
defaultValue: defaultIndex,
|
|
111
|
+
onChange: onIndexChange,
|
|
112
|
+
});
|
|
113
|
+
const index = Math.min(Math.max(rawIndex, 0), last);
|
|
114
|
+
const thumbs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
115
|
+
|
|
116
|
+
const openAt = (position: number) => {
|
|
117
|
+
setIndex(position);
|
|
118
|
+
setOpen(true);
|
|
119
|
+
};
|
|
120
|
+
const register = (position: number, node: HTMLButtonElement | null) => {
|
|
121
|
+
thumbs.current[position] = node;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<ul
|
|
127
|
+
{...props}
|
|
128
|
+
aria-label={label}
|
|
129
|
+
data-ratio={ratio}
|
|
130
|
+
className={cn(
|
|
131
|
+
"grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2 text-ink",
|
|
132
|
+
className,
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
{items.map((item, position) => (
|
|
136
|
+
<ItemContext
|
|
137
|
+
key={position}
|
|
138
|
+
value={{
|
|
139
|
+
position,
|
|
140
|
+
count,
|
|
141
|
+
ratio,
|
|
142
|
+
viewLabel,
|
|
143
|
+
open: openAt,
|
|
144
|
+
register,
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
{item}
|
|
148
|
+
</ItemContext>
|
|
149
|
+
))}
|
|
150
|
+
</ul>
|
|
151
|
+
<BaseDialog.Root open={open} onOpenChange={(next) => setOpen(next)}>
|
|
152
|
+
<BaseDialog.Portal>
|
|
153
|
+
<BaseDialog.Viewport className="fixed inset-0 z-(--z-overlay)">
|
|
154
|
+
<Stage
|
|
155
|
+
label={label}
|
|
156
|
+
items={items}
|
|
157
|
+
index={index}
|
|
158
|
+
setIndex={setIndex}
|
|
159
|
+
loop={loop}
|
|
160
|
+
previousLabel={previousLabel}
|
|
161
|
+
nextLabel={nextLabel}
|
|
162
|
+
closeLabel={closeLabel}
|
|
163
|
+
thumbs={thumbs}
|
|
164
|
+
onClose={() => setOpen(false)}
|
|
165
|
+
/>
|
|
166
|
+
</BaseDialog.Viewport>
|
|
167
|
+
</BaseDialog.Portal>
|
|
168
|
+
</BaseDialog.Root>
|
|
169
|
+
</>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type StageProps = {
|
|
174
|
+
label: string;
|
|
175
|
+
items: ReactElement<ItemLike>[];
|
|
176
|
+
index: number;
|
|
177
|
+
setIndex: (index: number) => void;
|
|
178
|
+
loop: boolean;
|
|
179
|
+
previousLabel: string;
|
|
180
|
+
nextLabel: string;
|
|
181
|
+
closeLabel: string;
|
|
182
|
+
thumbs: RefObject<(HTMLButtonElement | null)[]>;
|
|
183
|
+
onClose: () => void;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** 大图层。只在打开的时候挂着:轨道一挂上就直接对到被点的那一张 */
|
|
187
|
+
function Stage({
|
|
188
|
+
label,
|
|
189
|
+
items,
|
|
190
|
+
index,
|
|
191
|
+
setIndex,
|
|
192
|
+
loop,
|
|
193
|
+
previousLabel,
|
|
194
|
+
nextLabel,
|
|
195
|
+
closeLabel,
|
|
196
|
+
thumbs,
|
|
197
|
+
onClose,
|
|
198
|
+
}: StageProps) {
|
|
199
|
+
const count = items.length;
|
|
200
|
+
const last = Math.max(0, count - 1);
|
|
201
|
+
const { trackRef, onScroll } = useSnapTrack({
|
|
202
|
+
index,
|
|
203
|
+
count,
|
|
204
|
+
onSettle: setIndex,
|
|
205
|
+
});
|
|
206
|
+
const popupRef = useRef<HTMLDivElement>(null);
|
|
207
|
+
// 关的时候焦点回到哪张缩略图,要读那一刻的下标
|
|
208
|
+
const indexRef = useRef(index);
|
|
209
|
+
indexRef.current = index;
|
|
210
|
+
|
|
211
|
+
const go = (next: number) => {
|
|
212
|
+
if (count === 0) return;
|
|
213
|
+
const target = loop
|
|
214
|
+
? (next + count) % count
|
|
215
|
+
: Math.min(Math.max(next, 0), last);
|
|
216
|
+
setIndex(target);
|
|
217
|
+
// 到头了,刚按的那个翻页钮马上要禁用:把焦点交还给这一层,键盘还能接着用方向键翻回去
|
|
218
|
+
if (
|
|
219
|
+
!loop &&
|
|
220
|
+
(target === 0 || target === last) &&
|
|
221
|
+
document.activeElement instanceof HTMLButtonElement &&
|
|
222
|
+
document.activeElement.hasAttribute("data-image-viewer-nav")
|
|
223
|
+
) {
|
|
224
|
+
popupRef.current?.focus({ preventScroll: true });
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// 焦点在这一层的哪儿都能翻;视频自己的控件要用方向键,不抢
|
|
229
|
+
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
230
|
+
if (event.defaultPrevented) return;
|
|
231
|
+
if ((event.target as Element).closest("video, audio, input, textarea")) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const next = {
|
|
235
|
+
ArrowLeft: index - 1,
|
|
236
|
+
ArrowRight: index + 1,
|
|
237
|
+
Home: 0,
|
|
238
|
+
End: last,
|
|
239
|
+
}[event.key];
|
|
240
|
+
if (next === undefined) return;
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
go(next);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const current = items[index]?.props;
|
|
246
|
+
const many = count > 1;
|
|
247
|
+
const captioned = Boolean(current?.title || current?.description);
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<BaseDialog.Popup
|
|
251
|
+
// 两个主题下都是深色:里面的字、关闭钮、焦点环都按深色取值
|
|
252
|
+
data-theme="dark"
|
|
253
|
+
data-image-viewer=""
|
|
254
|
+
ref={popupRef}
|
|
255
|
+
// 打开时焦点落在这一层自己身上:方向键马上能用,Tab 再走到关闭钮和翻页钮。
|
|
256
|
+
// 它不是一个能操作的东西,所以没有焦点环——不然整个舞台外面会套一圈
|
|
257
|
+
initialFocus={popupRef}
|
|
258
|
+
finalFocus={() => thumbs.current[indexRef.current] ?? true}
|
|
259
|
+
onKeyDown={onKeyDown}
|
|
260
|
+
className={cn(
|
|
261
|
+
"flex size-full flex-col bg-surface text-ink outline-none",
|
|
262
|
+
"transition-opacity duration-(--duration-fast) ease-standard",
|
|
263
|
+
"data-ending-style:opacity-0 data-starting-style:opacity-0",
|
|
264
|
+
)}
|
|
265
|
+
>
|
|
266
|
+
<div className={cn(overlayHeader, "h-14 px-4")}>
|
|
267
|
+
<BaseDialog.Title className="sr-only">{label}</BaseDialog.Title>
|
|
268
|
+
{many && (
|
|
269
|
+
// 看得见的计数只给眼睛:同样的话下面的说明区会播报
|
|
270
|
+
<span
|
|
271
|
+
aria-hidden="true"
|
|
272
|
+
data-count=""
|
|
273
|
+
className="font-tech text-sm text-ink-secondary tabular-nums"
|
|
274
|
+
>
|
|
275
|
+
{`${index + 1} / ${count}`}
|
|
276
|
+
</span>
|
|
277
|
+
)}
|
|
278
|
+
<BaseDialog.Close aria-label={closeLabel} className={overlayClose}>
|
|
279
|
+
<Close size={24} />
|
|
280
|
+
</BaseDialog.Close>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{/* 取景角等整层淡入完了再落位:一起动的话被淡入盖住,看不见 */}
|
|
284
|
+
<Viewfinder
|
|
285
|
+
size="md"
|
|
286
|
+
animate
|
|
287
|
+
className="min-h-0 flex-1 [--bracket-delay:var(--duration-fast)]"
|
|
288
|
+
>
|
|
289
|
+
<div
|
|
290
|
+
ref={trackRef}
|
|
291
|
+
data-image-viewer-track=""
|
|
292
|
+
onScroll={onScroll}
|
|
293
|
+
className="absolute inset-0 flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain [scrollbar-width:none]"
|
|
294
|
+
>
|
|
295
|
+
{items.map((item, position) => (
|
|
296
|
+
<Slide
|
|
297
|
+
key={position}
|
|
298
|
+
label={`第 ${position + 1} 张,共 ${count} 张`}
|
|
299
|
+
current={position === index}
|
|
300
|
+
onClose={onClose}
|
|
301
|
+
>
|
|
302
|
+
{/* 只渲染当前这张和左右各一张:二十张图不会一打开就全去取 */}
|
|
303
|
+
{Math.abs(position - index) <= 1 ? item.props.children : null}
|
|
304
|
+
</Slide>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
</Viewfinder>
|
|
308
|
+
|
|
309
|
+
{(many || captioned) && (
|
|
310
|
+
<div className="flex shrink-0 flex-wrap items-start gap-x-6 gap-y-3 border-t border-line px-4 py-4">
|
|
311
|
+
{many && (
|
|
312
|
+
<div className="flex shrink-0 gap-2">
|
|
313
|
+
<IconButton
|
|
314
|
+
variant="floating"
|
|
315
|
+
aria-label={previousLabel}
|
|
316
|
+
data-image-viewer-nav=""
|
|
317
|
+
disabled={!loop && index <= 0}
|
|
318
|
+
onClick={() => go(index - 1)}
|
|
319
|
+
>
|
|
320
|
+
<ChevronLeft />
|
|
321
|
+
</IconButton>
|
|
322
|
+
<IconButton
|
|
323
|
+
variant="floating"
|
|
324
|
+
aria-label={nextLabel}
|
|
325
|
+
data-image-viewer-nav=""
|
|
326
|
+
disabled={!loop && index >= last}
|
|
327
|
+
onClick={() => go(index + 1)}
|
|
328
|
+
>
|
|
329
|
+
<ChevronRight />
|
|
330
|
+
</IconButton>
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
<div
|
|
334
|
+
aria-live="polite"
|
|
335
|
+
className="flex min-w-0 flex-1 basis-48 flex-col gap-1"
|
|
336
|
+
>
|
|
337
|
+
{many && (
|
|
338
|
+
<span className="sr-only">{`第 ${index + 1} 张,共 ${count} 张`}</span>
|
|
339
|
+
)}
|
|
340
|
+
{current?.title && (
|
|
341
|
+
<p className="text-base font-bold wrap-anywhere">
|
|
342
|
+
{current.title}
|
|
343
|
+
</p>
|
|
344
|
+
)}
|
|
345
|
+
{current?.description && (
|
|
346
|
+
<p className="max-h-24 overflow-y-auto text-sm wrap-anywhere text-ink-secondary">
|
|
347
|
+
{current.description}
|
|
348
|
+
</p>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
</BaseDialog.Popup>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
type SlideProps = {
|
|
358
|
+
label: string;
|
|
359
|
+
current: boolean;
|
|
360
|
+
onClose: () => void;
|
|
361
|
+
children: ReactNode;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 轨道上的一张。图是使用方给的元素,这里不碰它,只在外面听它的 `load`:
|
|
366
|
+
* 还没取到的图是透明的,取到了淡入——不是"啪"地一下出现。
|
|
367
|
+
* 只管直接放进来的 `<img>`;视频、别的内容不等,和原来一样直接在
|
|
368
|
+
*/
|
|
369
|
+
function Slide({ label, current, onClose, children }: SlideProps) {
|
|
370
|
+
// 取到过一次就算数:翻远了再翻回来,图是现成的,不用再藏一次
|
|
371
|
+
const [loaded, setLoaded] = useState(false);
|
|
372
|
+
const settle = () => setLoaded(true);
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<div
|
|
376
|
+
role="group"
|
|
377
|
+
aria-roledescription="幻灯片"
|
|
378
|
+
aria-label={label}
|
|
379
|
+
// 不在眼前的:Tab 走不进去,读屏也读不到
|
|
380
|
+
inert={!current}
|
|
381
|
+
data-current={current ? "" : undefined}
|
|
382
|
+
data-loaded={loaded ? "" : undefined}
|
|
383
|
+
// load 和 error 不冒泡,但捕获阶段从外往里走,在这一层听得到。
|
|
384
|
+
// 取不到的图也要露出来:使用方写的替代文字在那里
|
|
385
|
+
onLoadCapture={settle}
|
|
386
|
+
onErrorCapture={settle}
|
|
387
|
+
// 点图以外的空处就是关
|
|
388
|
+
onClick={(event) => {
|
|
389
|
+
if (event.target === event.currentTarget) onClose();
|
|
390
|
+
}}
|
|
391
|
+
className={cn(
|
|
392
|
+
"flex size-full shrink-0 snap-start items-center justify-center p-6 sm:p-10",
|
|
393
|
+
// 图按原比例整张放进去:不裁切,也不放大过它自己的尺寸
|
|
394
|
+
"*:max-h-full *:max-w-full [&>img]:object-contain [&>video]:object-contain",
|
|
395
|
+
"[&>img]:transition-opacity [&>img]:duration-(--duration-fast) [&>img]:ease-standard not-data-loaded:[&>img]:opacity-0",
|
|
396
|
+
)}
|
|
397
|
+
>
|
|
398
|
+
{children}
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export type ImageViewerItemProps = Omit<
|
|
404
|
+
ComponentProps<"li">,
|
|
405
|
+
"title" | "aria-label"
|
|
406
|
+
> & {
|
|
407
|
+
/** 这一张的标题,显示在大图下面;也用来给缩略图按钮起名 */
|
|
408
|
+
title?: ReactNode;
|
|
409
|
+
/** 标题下面的一段说明 */
|
|
410
|
+
description?: ReactNode;
|
|
411
|
+
/** 另给的小图,放在缩略图里。不传就用同一张图 */
|
|
412
|
+
thumbnail?: ReactNode;
|
|
413
|
+
/** 缩略图按钮的可访问名称。默认是"查看大图:标题",没有文字标题时报第几张 */
|
|
414
|
+
"aria-label"?: string;
|
|
415
|
+
/** 图:一个 `<img>`(或 `<video>`)。大图层里按原比例整张显示 */
|
|
416
|
+
children: ReactNode;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/** 图片查看里的一张。直接放在 `ImageViewer` 里。 */
|
|
420
|
+
export function ImageViewerItem({
|
|
421
|
+
title,
|
|
422
|
+
// 说明由 ImageViewer 读走,画在大图下面
|
|
423
|
+
description: _description,
|
|
424
|
+
thumbnail,
|
|
425
|
+
"aria-label": ariaLabel,
|
|
426
|
+
className,
|
|
427
|
+
children,
|
|
428
|
+
...props
|
|
429
|
+
}: ImageViewerItemProps) {
|
|
430
|
+
const item = useContext(ItemContext);
|
|
431
|
+
if (!item) {
|
|
432
|
+
throw new Error("ImageViewerItem 要直接放在 ImageViewer 里面");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const name =
|
|
436
|
+
ariaLabel ??
|
|
437
|
+
`${item.viewLabel}:${
|
|
438
|
+
typeof title === "string"
|
|
439
|
+
? title
|
|
440
|
+
: `第 ${item.position + 1} 张,共 ${item.count} 张`
|
|
441
|
+
}`;
|
|
442
|
+
|
|
443
|
+
return (
|
|
444
|
+
<li {...props} className={cn("min-w-0", className)}>
|
|
445
|
+
<button
|
|
446
|
+
type="button"
|
|
447
|
+
ref={(node) => item.register(item.position, node)}
|
|
448
|
+
aria-label={name}
|
|
449
|
+
aria-haspopup="dialog"
|
|
450
|
+
onClick={() => item.open(item.position)}
|
|
451
|
+
className={cn(
|
|
452
|
+
"group/thumb relative block w-full overflow-clip rounded-md bg-surface-muted",
|
|
453
|
+
"[&>img]:size-full [&>img]:object-cover [&>svg]:size-full [&>video]:size-full [&>video]:object-cover",
|
|
454
|
+
focusRing,
|
|
455
|
+
ratioClass[item.ratio],
|
|
456
|
+
)}
|
|
457
|
+
>
|
|
458
|
+
{thumbnail ?? children}
|
|
459
|
+
{/* 悬停:一条底边强调线从左充出,同媒体卡。不放大、不提亮 */}
|
|
460
|
+
<span
|
|
461
|
+
aria-hidden="true"
|
|
462
|
+
className="pointer-events-none absolute inset-x-0 bottom-0 h-1 origin-left scale-x-0 bg-action transition-transform duration-(--duration-base) ease-standard group-hover/thumb:scale-x-100 group-focus-visible/thumb:scale-x-100"
|
|
463
|
+
/>
|
|
464
|
+
</button>
|
|
465
|
+
</li>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
@@ -33,6 +33,7 @@ type OwnProps = {
|
|
|
33
33
|
/** 右上角的小圆位:已装备者的头像 */
|
|
34
34
|
badge?: ReactNode;
|
|
35
35
|
/** 售罄 / 不可用:整卡压一层遮罩。传文字("售罄")会写在遮罩上 */
|
|
36
|
+
// oxlint-disable-next-line typescript/no-redundant-type-constituents -- ReactNode 本来就含 boolean,写出来是让人一眼看到两种用法
|
|
36
37
|
unavailable?: boolean | ReactNode;
|
|
37
38
|
/** 传了就整格渲染成链接 */
|
|
38
39
|
href?: string;
|
|
@@ -273,7 +274,8 @@ export function ItemSlot({
|
|
|
273
274
|
className={cn(
|
|
274
275
|
"relative",
|
|
275
276
|
ratioClass[ratio],
|
|
276
|
-
|
|
277
|
+
// 选中的那一刻括号从角上伸出来;取消时直接消失
|
|
278
|
+
selected && "corner-brackets after:animate-bracket-in",
|
|
277
279
|
className,
|
|
278
280
|
)}
|
|
279
281
|
>
|
|
@@ -85,6 +85,7 @@ export function NavMenu({
|
|
|
85
85
|
initialFocus={firstFocusableIn(bodyRef)}
|
|
86
86
|
className={cn(
|
|
87
87
|
"fixed inset-0 z-(--z-overlay) flex flex-col overflow-hidden bg-surface text-ink outline-none",
|
|
88
|
+
// 整层淡入;里面的栏目另有自己的入场(见 itemEnter)
|
|
88
89
|
"transition-opacity duration-(--duration-fast) ease-standard",
|
|
89
90
|
"data-ending-style:opacity-0 data-starting-style:opacity-0",
|
|
90
91
|
className,
|
|
@@ -155,6 +156,17 @@ export function NavMenu({
|
|
|
155
156
|
);
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
/*
|
|
160
|
+
* 栏目入场:逐条从左滑入 8px。第一项等层淡入过半(100ms),之后每项晚 50ms,
|
|
161
|
+
* 第六项起一起到——从第一项动到最后一项停下 550ms。
|
|
162
|
+
* 只动 <li> 的位置和透明度:焦点一打开就在第一项上,键盘不用等它
|
|
163
|
+
*/
|
|
164
|
+
const itemEnter = [
|
|
165
|
+
"animate-shift-in [--shift-x:calc(var(--motion-shift-lg)*-1)]",
|
|
166
|
+
"[animation-delay:100ms] nth-2:[animation-delay:150ms] nth-3:[animation-delay:200ms]",
|
|
167
|
+
"nth-4:[animation-delay:250ms] nth-5:[animation-delay:300ms] nth-[n+6]:[animation-delay:350ms]",
|
|
168
|
+
].join(" ");
|
|
169
|
+
|
|
158
170
|
type ItemOwnProps = {
|
|
159
171
|
/** 图标,后面跟一条竖向的分隔线 */
|
|
160
172
|
icon?: ReactNode;
|
|
@@ -188,6 +200,7 @@ export function NavMenuItem({
|
|
|
188
200
|
rel,
|
|
189
201
|
render,
|
|
190
202
|
onClick,
|
|
203
|
+
className,
|
|
191
204
|
children,
|
|
192
205
|
...props
|
|
193
206
|
}: NavMenuItemProps) {
|
|
@@ -256,7 +269,7 @@ export function NavMenuItem({
|
|
|
256
269
|
);
|
|
257
270
|
|
|
258
271
|
return (
|
|
259
|
-
<li {...props}>
|
|
272
|
+
<li {...props} className={cn(itemEnter, className)}>
|
|
260
273
|
{isLink ? (
|
|
261
274
|
<LinkElement
|
|
262
275
|
render={render}
|