@endfield-ui/react 0.1.0 → 0.2.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.
@@ -2,10 +2,7 @@ import {
2
2
  Children,
3
3
  createContext,
4
4
  isValidElement,
5
- useCallback,
6
5
  useContext,
7
- useEffect,
8
- useRef,
9
6
  type ComponentProps,
10
7
  type KeyboardEvent,
11
8
  type ReactNode,
@@ -17,6 +14,7 @@ import { cn } from "../../lib/cn";
17
14
  import { focusRing } from "../../lib/focus-ring";
18
15
  import { DashIndicator } from "../dash-indicator/DashIndicator";
19
16
  import { IconButton } from "../icon-button/IconButton";
17
+ import { useSnapTrack } from "./snap-track";
20
18
 
21
19
  export type CarouselRatio = "16/9" | "4/3" | "1/1" | "3/4" | "21/9";
22
20
 
@@ -58,9 +56,6 @@ export type CarouselProps = Omit<
58
56
 
59
57
  type SlideLike = { title?: ReactNode; description?: ReactNode };
60
58
 
61
- /** 滚动停下之后多久算"停稳了"。没有 scrollend 事件的浏览器靠它 */
62
- const SETTLE_MS = 120;
63
-
64
59
  /**
65
60
  * 媒体轮播:一次看一张的大幅媒体,前后翻。不自动播放。
66
61
  * 轨道是原生的横向滚动加滚动吸附,触屏滑动是浏览器自带的。
@@ -91,11 +86,12 @@ export function Carousel({
91
86
  onChange: onIndexChange,
92
87
  });
93
88
  const index = Math.min(Math.max(rawIndex, 0), last);
94
- const trackRef = useRef<HTMLDivElement>(null);
95
- const settleTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
96
- // 给不跟着渲染重建的回调(尺寸变化)读最新的下标
97
- const indexRef = useRef(index);
98
- indexRef.current = index;
89
+ // 下标和滚动位置互相跟的那一段,和图片查看共用
90
+ const { trackRef, onScroll } = useSnapTrack({
91
+ index,
92
+ count,
93
+ onSettle: setIndex,
94
+ });
99
95
 
100
96
  const go = (next: number) => {
101
97
  if (count === 0) return;
@@ -114,42 +110,6 @@ export function Carousel({
114
110
  }
115
111
  };
116
112
 
117
- // 下标变了:滚过去。已经在那儿(用户自己滑过去的)就不动
118
- useEffect(() => {
119
- const track = trackRef.current;
120
- if (!track || track.clientWidth === 0) return;
121
- if (Math.round(track.scrollLeft / track.clientWidth) === index) return;
122
- track.scrollTo?.({ left: index * track.clientWidth, behavior: "smooth" });
123
- }, [index]);
124
-
125
- // 宽度变了:轨道的滚动位置是像素,会错开,重新对到当前这一张(不要动画)
126
- useEffect(() => {
127
- const track = trackRef.current;
128
- if (!track || typeof ResizeObserver === "undefined") return;
129
- const observer = new ResizeObserver(() => {
130
- track.scrollTo?.({
131
- left: indexRef.current * track.clientWidth,
132
- behavior: "instant",
133
- });
134
- });
135
- observer.observe(track);
136
- return () => observer.disconnect();
137
- }, []);
138
-
139
- useEffect(() => () => clearTimeout(settleTimer.current), []);
140
-
141
- // 用户自己滑:等滚动停稳,由停下的位置定下标。滚动途中不改——
142
- // 翻页钮触发的滚动会路过中间那几张
143
- const onScroll = useCallback(() => {
144
- clearTimeout(settleTimer.current);
145
- settleTimer.current = setTimeout(() => {
146
- const track = trackRef.current;
147
- if (!track || track.clientWidth === 0) return;
148
- const stopped = Math.round(track.scrollLeft / track.clientWidth);
149
- setIndex(Math.min(Math.max(stopped, 0), last));
150
- }, SETTLE_MS);
151
- }, [setIndex, last]);
152
-
153
113
  const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
154
114
  const next = {
155
115
  ArrowLeft: index - 1,
@@ -0,0 +1,74 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+
3
+ /** 滚动停下之后多久算"停稳了"。没有 scrollend 事件的浏览器靠它 */
4
+ const SETTLE_MS = 120;
5
+
6
+ type SnapTrackOptions = {
7
+ /** 现在是第几张,从 0 起 */
8
+ index: number;
9
+ /** 一共几张 */
10
+ count: number;
11
+ /** 用户自己滑、停稳在了第几张 */
12
+ onSettle: (index: number) => void;
13
+ };
14
+
15
+ /**
16
+ * 一条横向滚动加滚动吸附的轨道,每一张和轨道一样宽。下标和滚动位置互相跟:
17
+ * 下标变了轨道滚过去;用户自己滑的时候反过来,等滚动停稳,由停下的位置定下标。
18
+ * 手势、惯性都是浏览器的。媒体轮播和图片查看共用,不对外导出。
19
+ *
20
+ * 把 `trackRef` 和 `onScroll` 交给那个滚动容器。
21
+ */
22
+ export function useSnapTrack({ index, count, onSettle }: SnapTrackOptions) {
23
+ const trackRef = useRef<HTMLDivElement>(null);
24
+ const settleTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
25
+ const last = Math.max(0, count - 1);
26
+ // 给不跟着渲染重建的回调(尺寸变化)读最新的下标
27
+ const indexRef = useRef(index);
28
+ indexRef.current = index;
29
+ const placed = useRef(false);
30
+
31
+ // 下标变了:滚过去。已经在那儿(用户自己滑过去的)就不动。
32
+ // 刚挂上的那一次直接到位:从第一张一路滑到第三张不是谁想看的
33
+ useEffect(() => {
34
+ const first = !placed.current;
35
+ placed.current = true;
36
+ const track = trackRef.current;
37
+ if (!track || track.clientWidth === 0) return;
38
+ if (Math.round(track.scrollLeft / track.clientWidth) === index) return;
39
+ track.scrollTo?.({
40
+ left: index * track.clientWidth,
41
+ behavior: first ? "instant" : "smooth",
42
+ });
43
+ }, [index]);
44
+
45
+ // 宽度变了:轨道的滚动位置是像素,会错开,重新对到当前这一张(不要动画)
46
+ useEffect(() => {
47
+ const track = trackRef.current;
48
+ if (!track || typeof ResizeObserver === "undefined") return;
49
+ const observer = new ResizeObserver(() => {
50
+ track.scrollTo?.({
51
+ left: indexRef.current * track.clientWidth,
52
+ behavior: "instant",
53
+ });
54
+ });
55
+ observer.observe(track);
56
+ return () => observer.disconnect();
57
+ }, []);
58
+
59
+ useEffect(() => () => clearTimeout(settleTimer.current), []);
60
+
61
+ // 用户自己滑:等滚动停稳,由停下的位置定下标。滚动途中不改——
62
+ // 翻页钮触发的滚动会路过中间那几张
63
+ const onScroll = useCallback(() => {
64
+ clearTimeout(settleTimer.current);
65
+ settleTimer.current = setTimeout(() => {
66
+ const track = trackRef.current;
67
+ if (!track || track.clientWidth === 0) return;
68
+ const stopped = Math.round(track.scrollLeft / track.clientWidth);
69
+ onSettle(Math.min(Math.max(stopped, 0), last));
70
+ }, SETTLE_MS);
71
+ }, [onSettle, last]);
72
+
73
+ return { trackRef, onScroll };
74
+ }
@@ -0,0 +1,101 @@
1
+ import { PreviewCard as BasePreviewCard } from "@base-ui/react/preview-card";
2
+ import type { ComponentProps, ReactElement, ReactNode } from "react";
3
+ import { usePortalScope } from "../../hooks/usePortalScope";
4
+ import { cn } from "../../lib/cn";
5
+ import {
6
+ popoverDescription,
7
+ popoverPanel,
8
+ popoverTitle,
9
+ } from "../popover/panel-style";
10
+
11
+ export type HoverCardSide = "top" | "bottom" | "left" | "right";
12
+ export type HoverCardAlign = "start" | "center" | "end";
13
+
14
+ export type HoverCardProps = Omit<
15
+ ComponentProps<"div">,
16
+ "title" | "children" | "className" | "style"
17
+ > & {
18
+ /** 触发元素:一个链接(`<a>`、带 `href` 的按钮、路由库的链接组件) */
19
+ trigger: ReactElement<Record<string, unknown>>;
20
+ /** 卡片里的标题 */
21
+ title?: ReactNode;
22
+ /** 标题下的一行说明 */
23
+ description?: ReactNode;
24
+ /** 卡片出现在链接的哪一侧,放不下时自动翻到对面。默认 `bottom` */
25
+ side?: HoverCardSide;
26
+ /** 和链接的哪一边对齐。默认 `start` */
27
+ align?: HoverCardAlign;
28
+ /** 指针停住多久后出现,毫秒,默认 600。键盘聚焦时同样要等 */
29
+ delay?: number;
30
+ /** 指针离开多久后消失,毫秒,默认 300:够把指针移进卡片里 */
31
+ closeDelay?: number;
32
+ open?: boolean;
33
+ defaultOpen?: boolean;
34
+ onOpenChange?: (open: boolean) => void;
35
+ /** 给卡片 */
36
+ className?: string;
37
+ children?: ReactNode;
38
+ };
39
+
40
+ /**
41
+ * 悬浮卡:指针停在一个链接上时,在旁边预览它的去处。面板和气泡卡片是同一块。
42
+ *
43
+ * 触屏上不出现,读屏也不会读到它:里面只放"点进去也看得到"的内容,
44
+ * 不放只有这里才有的信息和操作。要操作的用 `Popover`,一句说明用 `Tooltip`。
45
+ */
46
+ export function HoverCard({
47
+ trigger,
48
+ title,
49
+ description,
50
+ side = "bottom",
51
+ align = "start",
52
+ delay = 600,
53
+ closeDelay = 300,
54
+ open,
55
+ defaultOpen,
56
+ onOpenChange,
57
+ className,
58
+ children,
59
+ ...props
60
+ }: HoverCardProps) {
61
+ const { anchorRef, portalRef } = usePortalScope();
62
+
63
+ return (
64
+ <BasePreviewCard.Root
65
+ open={open}
66
+ defaultOpen={defaultOpen}
67
+ onOpenChange={onOpenChange && ((next) => onOpenChange(next))}
68
+ >
69
+ <BasePreviewCard.Trigger
70
+ ref={anchorRef}
71
+ render={trigger}
72
+ delay={delay}
73
+ closeDelay={closeDelay}
74
+ />
75
+ <BasePreviewCard.Portal ref={portalRef}>
76
+ <BasePreviewCard.Positioner
77
+ side={side}
78
+ align={align}
79
+ sideOffset={4}
80
+ className="z-(--z-overlay) outline-none"
81
+ >
82
+ <BasePreviewCard.Popup
83
+ {...props}
84
+ data-hover-card=""
85
+ className={cn(popoverPanel, className)}
86
+ >
87
+ {(title || description) && (
88
+ <div className="flex flex-col gap-1">
89
+ {title && <p className={popoverTitle}>{title}</p>}
90
+ {description && (
91
+ <p className={popoverDescription}>{description}</p>
92
+ )}
93
+ </div>
94
+ )}
95
+ {children}
96
+ </BasePreviewCard.Popup>
97
+ </BasePreviewCard.Positioner>
98
+ </BasePreviewCard.Portal>
99
+ </BasePreviewCard.Root>
100
+ );
101
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ HoverCard,
3
+ type HoverCardAlign,
4
+ type HoverCardProps,
5
+ type HoverCardSide,
6
+ } from "./HoverCard";
@@ -0,0 +1,427 @@
1
+ import { Dialog as BaseDialog } from "@base-ui/react/dialog";
2
+ import {
3
+ Children,
4
+ createContext,
5
+ isValidElement,
6
+ useContext,
7
+ useRef,
8
+ type ComponentProps,
9
+ type KeyboardEvent,
10
+ type ReactElement,
11
+ type ReactNode,
12
+ type RefObject,
13
+ } from "react";
14
+ import { useControllableState } from "../../hooks/useControllableState";
15
+ import { ChevronLeft } from "../../icons/ChevronLeft";
16
+ import { ChevronRight } from "../../icons/ChevronRight";
17
+ import { Close } from "../../icons/Close";
18
+ import { cn } from "../../lib/cn";
19
+ import { focusRing } from "../../lib/focus-ring";
20
+ import { useSnapTrack } from "../carousel/snap-track";
21
+ import { overlayClose, overlayHeader } from "../dialog/overlay-style";
22
+ import { IconButton } from "../icon-button/IconButton";
23
+ import { Viewfinder } from "../viewfinder/Viewfinder";
24
+
25
+ export type ImageViewerRatio = "16/9" | "4/3" | "1/1" | "3/4";
26
+
27
+ const ratioClass: Record<ImageViewerRatio, string> = {
28
+ "16/9": "aspect-video",
29
+ "4/3": "aspect-4/3",
30
+ "1/1": "aspect-square",
31
+ "3/4": "aspect-3/4",
32
+ };
33
+
34
+ type ItemLike = {
35
+ title?: ReactNode;
36
+ description?: ReactNode;
37
+ children?: ReactNode;
38
+ };
39
+
40
+ const ItemContext = createContext<{
41
+ position: number;
42
+ count: number;
43
+ ratio: ImageViewerRatio;
44
+ viewLabel: string;
45
+ open: (position: number) => void;
46
+ register: (position: number, node: HTMLButtonElement | null) => void;
47
+ } | null>(null);
48
+
49
+ export type ImageViewerProps = Omit<ComponentProps<"ul">, "aria-label"> & {
50
+ /** 这组图是什么,比如"现场照片"。缩略图的列表和大图层都用它命名 */
51
+ "aria-label": string;
52
+ /** 大图层开没开 */
53
+ open?: boolean;
54
+ onOpenChange?: (open: boolean) => void;
55
+ /** 大图层现在是第几张,从 0 起 */
56
+ index?: number;
57
+ defaultIndex?: number;
58
+ onIndexChange?: (index: number) => void;
59
+ /** 到头之后绕回另一头。默认到头时翻页钮禁用 */
60
+ loop?: boolean;
61
+ /** 缩略图的宽高比,默认 `4/3`。大图不裁切,按原比例整张显示 */
62
+ ratio?: ImageViewerRatio;
63
+ /** 缩略图按钮名称的开头,默认"查看大图" */
64
+ viewLabel?: string;
65
+ previousLabel?: string;
66
+ nextLabel?: string;
67
+ closeLabel?: string;
68
+ /** 直接放 `ImageViewerItem` */
69
+ children: ReactNode;
70
+ };
71
+
72
+ /**
73
+ * 图片查看:一组缩略图,点一张放大到整屏看,前后翻。
74
+ * 大图那一层在亮暗两个主题下都是深色;翻页用的是媒体轮播的那条轨道,触屏可以左右滑。
75
+ *
76
+ * `className` 和其余属性给缩略图的列表。
77
+ */
78
+ export function ImageViewer({
79
+ "aria-label": label,
80
+ open: openProp,
81
+ onOpenChange,
82
+ index: indexProp,
83
+ defaultIndex = 0,
84
+ onIndexChange,
85
+ loop = false,
86
+ ratio = "4/3",
87
+ viewLabel = "查看大图",
88
+ previousLabel = "上一张",
89
+ nextLabel = "下一张",
90
+ closeLabel = "关闭",
91
+ className,
92
+ children,
93
+ ...props
94
+ }: ImageViewerProps) {
95
+ // 标题、说明和大图都从子元素上读,所以子元素必须直接是 ImageViewerItem
96
+ const items = Children.toArray(children).filter((child) =>
97
+ isValidElement<ItemLike>(child),
98
+ );
99
+ const count = items.length;
100
+ const last = Math.max(0, count - 1);
101
+
102
+ const [open, setOpen] = useControllableState({
103
+ value: openProp,
104
+ defaultValue: false,
105
+ onChange: onOpenChange,
106
+ });
107
+ const [rawIndex, setIndex] = useControllableState({
108
+ value: indexProp,
109
+ defaultValue: defaultIndex,
110
+ onChange: onIndexChange,
111
+ });
112
+ const index = Math.min(Math.max(rawIndex, 0), last);
113
+ const thumbs = useRef<(HTMLButtonElement | null)[]>([]);
114
+
115
+ const openAt = (position: number) => {
116
+ setIndex(position);
117
+ setOpen(true);
118
+ };
119
+ const register = (position: number, node: HTMLButtonElement | null) => {
120
+ thumbs.current[position] = node;
121
+ };
122
+
123
+ return (
124
+ <>
125
+ <ul
126
+ {...props}
127
+ aria-label={label}
128
+ data-ratio={ratio}
129
+ className={cn(
130
+ "grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2 text-ink",
131
+ className,
132
+ )}
133
+ >
134
+ {items.map((item, position) => (
135
+ <ItemContext
136
+ key={position}
137
+ value={{
138
+ position,
139
+ count,
140
+ ratio,
141
+ viewLabel,
142
+ open: openAt,
143
+ register,
144
+ }}
145
+ >
146
+ {item}
147
+ </ItemContext>
148
+ ))}
149
+ </ul>
150
+ <BaseDialog.Root open={open} onOpenChange={(next) => setOpen(next)}>
151
+ <BaseDialog.Portal>
152
+ <BaseDialog.Viewport className="fixed inset-0 z-(--z-overlay)">
153
+ <Stage
154
+ label={label}
155
+ items={items}
156
+ index={index}
157
+ setIndex={setIndex}
158
+ loop={loop}
159
+ previousLabel={previousLabel}
160
+ nextLabel={nextLabel}
161
+ closeLabel={closeLabel}
162
+ thumbs={thumbs}
163
+ onClose={() => setOpen(false)}
164
+ />
165
+ </BaseDialog.Viewport>
166
+ </BaseDialog.Portal>
167
+ </BaseDialog.Root>
168
+ </>
169
+ );
170
+ }
171
+
172
+ type StageProps = {
173
+ label: string;
174
+ items: ReactElement<ItemLike>[];
175
+ index: number;
176
+ setIndex: (index: number) => void;
177
+ loop: boolean;
178
+ previousLabel: string;
179
+ nextLabel: string;
180
+ closeLabel: string;
181
+ thumbs: RefObject<(HTMLButtonElement | null)[]>;
182
+ onClose: () => void;
183
+ };
184
+
185
+ /** 大图层。只在打开的时候挂着:轨道一挂上就直接对到被点的那一张 */
186
+ function Stage({
187
+ label,
188
+ items,
189
+ index,
190
+ setIndex,
191
+ loop,
192
+ previousLabel,
193
+ nextLabel,
194
+ closeLabel,
195
+ thumbs,
196
+ onClose,
197
+ }: StageProps) {
198
+ const count = items.length;
199
+ const last = Math.max(0, count - 1);
200
+ const { trackRef, onScroll } = useSnapTrack({
201
+ index,
202
+ count,
203
+ onSettle: setIndex,
204
+ });
205
+ const popupRef = useRef<HTMLDivElement>(null);
206
+ // 关的时候焦点回到哪张缩略图,要读那一刻的下标
207
+ const indexRef = useRef(index);
208
+ indexRef.current = index;
209
+
210
+ const go = (next: number) => {
211
+ if (count === 0) return;
212
+ const target = loop
213
+ ? (next + count) % count
214
+ : Math.min(Math.max(next, 0), last);
215
+ setIndex(target);
216
+ // 到头了,刚按的那个翻页钮马上要禁用:把焦点交还给这一层,键盘还能接着用方向键翻回去
217
+ if (
218
+ !loop &&
219
+ (target === 0 || target === last) &&
220
+ document.activeElement instanceof HTMLButtonElement &&
221
+ document.activeElement.hasAttribute("data-image-viewer-nav")
222
+ ) {
223
+ popupRef.current?.focus({ preventScroll: true });
224
+ }
225
+ };
226
+
227
+ // 焦点在这一层的哪儿都能翻;视频自己的控件要用方向键,不抢
228
+ const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
229
+ if (event.defaultPrevented) return;
230
+ if ((event.target as Element).closest("video, audio, input, textarea")) {
231
+ return;
232
+ }
233
+ const next = {
234
+ ArrowLeft: index - 1,
235
+ ArrowRight: index + 1,
236
+ Home: 0,
237
+ End: last,
238
+ }[event.key];
239
+ if (next === undefined) return;
240
+ event.preventDefault();
241
+ go(next);
242
+ };
243
+
244
+ const current = items[index]?.props;
245
+ const many = count > 1;
246
+ const captioned = Boolean(current?.title || current?.description);
247
+
248
+ return (
249
+ <BaseDialog.Popup
250
+ // 两个主题下都是深色:里面的字、关闭钮、焦点环都按深色取值
251
+ data-theme="dark"
252
+ data-image-viewer=""
253
+ ref={popupRef}
254
+ // 打开时焦点落在这一层自己身上:方向键马上能用,Tab 再走到关闭钮和翻页钮。
255
+ // 它不是一个能操作的东西,所以没有焦点环——不然整个舞台外面会套一圈
256
+ initialFocus={popupRef}
257
+ finalFocus={() => thumbs.current[indexRef.current] ?? true}
258
+ onKeyDown={onKeyDown}
259
+ className={cn(
260
+ "flex size-full flex-col bg-surface text-ink outline-none",
261
+ "transition-opacity duration-(--duration-fast) ease-standard",
262
+ "data-ending-style:opacity-0 data-starting-style:opacity-0",
263
+ )}
264
+ >
265
+ <div className={cn(overlayHeader, "h-14 px-4")}>
266
+ <BaseDialog.Title className="sr-only">{label}</BaseDialog.Title>
267
+ {many && (
268
+ // 看得见的计数只给眼睛:同样的话下面的说明区会播报
269
+ <span
270
+ aria-hidden="true"
271
+ data-count=""
272
+ className="font-tech text-sm text-ink-secondary tabular-nums"
273
+ >
274
+ {`${index + 1} / ${count}`}
275
+ </span>
276
+ )}
277
+ <BaseDialog.Close aria-label={closeLabel} className={overlayClose}>
278
+ <Close size={24} />
279
+ </BaseDialog.Close>
280
+ </div>
281
+
282
+ <Viewfinder size="md" className="min-h-0 flex-1">
283
+ <div
284
+ ref={trackRef}
285
+ data-image-viewer-track=""
286
+ onScroll={onScroll}
287
+ className="absolute inset-0 flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain [scrollbar-width:none]"
288
+ >
289
+ {items.map((item, position) => (
290
+ <div
291
+ key={position}
292
+ role="group"
293
+ aria-roledescription="幻灯片"
294
+ aria-label={`第 ${position + 1} 张,共 ${count} 张`}
295
+ // 不在眼前的:Tab 走不进去,读屏也读不到
296
+ inert={position !== index}
297
+ data-current={position === index ? "" : undefined}
298
+ // 点图以外的空处就是关
299
+ onClick={(event) => {
300
+ if (event.target === event.currentTarget) onClose();
301
+ }}
302
+ className={cn(
303
+ "flex size-full shrink-0 snap-start items-center justify-center p-6 sm:p-10",
304
+ // 图按原比例整张放进去:不裁切,也不放大过它自己的尺寸
305
+ "*:max-h-full *:max-w-full [&>img]:object-contain [&>video]:object-contain",
306
+ )}
307
+ >
308
+ {/* 只渲染当前这张和左右各一张:二十张图不会一打开就全去取 */}
309
+ {Math.abs(position - index) <= 1 ? item.props.children : null}
310
+ </div>
311
+ ))}
312
+ </div>
313
+ </Viewfinder>
314
+
315
+ {(many || captioned) && (
316
+ <div className="flex shrink-0 flex-wrap items-start gap-x-6 gap-y-3 border-t border-line px-4 py-4">
317
+ {many && (
318
+ <div className="flex shrink-0 gap-2">
319
+ <IconButton
320
+ variant="floating"
321
+ aria-label={previousLabel}
322
+ data-image-viewer-nav=""
323
+ disabled={!loop && index <= 0}
324
+ onClick={() => go(index - 1)}
325
+ >
326
+ <ChevronLeft />
327
+ </IconButton>
328
+ <IconButton
329
+ variant="floating"
330
+ aria-label={nextLabel}
331
+ data-image-viewer-nav=""
332
+ disabled={!loop && index >= last}
333
+ onClick={() => go(index + 1)}
334
+ >
335
+ <ChevronRight />
336
+ </IconButton>
337
+ </div>
338
+ )}
339
+ <div
340
+ aria-live="polite"
341
+ className="flex min-w-0 flex-1 basis-48 flex-col gap-1"
342
+ >
343
+ {many && (
344
+ <span className="sr-only">{`第 ${index + 1} 张,共 ${count} 张`}</span>
345
+ )}
346
+ {current?.title && (
347
+ <p className="text-base font-bold wrap-anywhere">
348
+ {current.title}
349
+ </p>
350
+ )}
351
+ {current?.description && (
352
+ <p className="max-h-24 overflow-y-auto text-sm wrap-anywhere text-ink-secondary">
353
+ {current.description}
354
+ </p>
355
+ )}
356
+ </div>
357
+ </div>
358
+ )}
359
+ </BaseDialog.Popup>
360
+ );
361
+ }
362
+
363
+ export type ImageViewerItemProps = Omit<
364
+ ComponentProps<"li">,
365
+ "title" | "aria-label"
366
+ > & {
367
+ /** 这一张的标题,显示在大图下面;也用来给缩略图按钮起名 */
368
+ title?: ReactNode;
369
+ /** 标题下面的一段说明 */
370
+ description?: ReactNode;
371
+ /** 另给的小图,放在缩略图里。不传就用同一张图 */
372
+ thumbnail?: ReactNode;
373
+ /** 缩略图按钮的可访问名称。默认是"查看大图:标题",没有文字标题时报第几张 */
374
+ "aria-label"?: string;
375
+ /** 图:一个 `<img>`(或 `<video>`)。大图层里按原比例整张显示 */
376
+ children: ReactNode;
377
+ };
378
+
379
+ /** 图片查看里的一张。直接放在 `ImageViewer` 里。 */
380
+ export function ImageViewerItem({
381
+ title,
382
+ // 说明由 ImageViewer 读走,画在大图下面
383
+ description: _description,
384
+ thumbnail,
385
+ "aria-label": ariaLabel,
386
+ className,
387
+ children,
388
+ ...props
389
+ }: ImageViewerItemProps) {
390
+ const item = useContext(ItemContext);
391
+ if (!item) {
392
+ throw new Error("ImageViewerItem 要直接放在 ImageViewer 里面");
393
+ }
394
+
395
+ const name =
396
+ ariaLabel ??
397
+ `${item.viewLabel}:${
398
+ typeof title === "string"
399
+ ? title
400
+ : `第 ${item.position + 1} 张,共 ${item.count} 张`
401
+ }`;
402
+
403
+ return (
404
+ <li {...props} className={cn("min-w-0", className)}>
405
+ <button
406
+ type="button"
407
+ ref={(node) => item.register(item.position, node)}
408
+ aria-label={name}
409
+ aria-haspopup="dialog"
410
+ onClick={() => item.open(item.position)}
411
+ className={cn(
412
+ "group/thumb relative block w-full overflow-clip rounded-md bg-surface-muted",
413
+ "[&>img]:size-full [&>img]:object-cover [&>svg]:size-full [&>video]:size-full [&>video]:object-cover",
414
+ focusRing,
415
+ ratioClass[item.ratio],
416
+ )}
417
+ >
418
+ {thumbnail ?? children}
419
+ {/* 悬停:一条底边强调线从左充出,同媒体卡。不放大、不提亮 */}
420
+ <span
421
+ aria-hidden="true"
422
+ 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"
423
+ />
424
+ </button>
425
+ </li>
426
+ );
427
+ }