@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.
Files changed (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +55 -5
  3. package/dist/index.d.ts +213 -4
  4. package/dist/index.js +688 -109
  5. package/dist/styles.css +1 -1
  6. package/package.json +1 -1
  7. package/src/components/README.md +8 -1
  8. package/src/components/avatar/Avatar.tsx +3 -0
  9. package/src/components/carousel/Carousel.tsx +7 -47
  10. package/src/components/carousel/snap-track.ts +74 -0
  11. package/src/components/checkbox/choice-style.ts +5 -2
  12. package/src/components/corner-brackets/CornerBrackets.tsx +2 -1
  13. package/src/components/dialog/Dialog.tsx +4 -2
  14. package/src/components/dropdown-menu/menu-style.ts +9 -2
  15. package/src/components/file-upload/FileUpload.tsx +1 -1
  16. package/src/components/hover-card/HoverCard.tsx +101 -0
  17. package/src/components/hover-card/index.ts +6 -0
  18. package/src/components/image-viewer/ImageViewer.tsx +467 -0
  19. package/src/components/image-viewer/index.ts +7 -0
  20. package/src/components/item-slot/ItemSlot.tsx +3 -1
  21. package/src/components/nav-menu/NavMenu.tsx +14 -1
  22. package/src/components/otp-input/OtpInput.tsx +214 -0
  23. package/src/components/otp-input/index.ts +7 -0
  24. package/src/components/page-header/PageHeader.tsx +182 -0
  25. package/src/components/page-header/index.ts +6 -0
  26. package/src/components/popover/Popover.tsx +4 -12
  27. package/src/components/popover/panel-style.ts +15 -0
  28. package/src/components/scroll-area/ScrollArea.tsx +137 -0
  29. package/src/components/scroll-area/index.ts +5 -0
  30. package/src/components/slider/Slider.tsx +6 -2
  31. package/src/components/steps/Steps.tsx +12 -3
  32. package/src/components/table/Table.tsx +1 -0
  33. package/src/components/tag-input/TagInput.tsx +1 -0
  34. package/src/components/toast/Toast.tsx +18 -5
  35. package/src/components/toc/Toc.tsx +1 -0
  36. package/src/components/tooltip/Tooltip.tsx +5 -1
  37. package/src/components/viewfinder/Viewfinder.tsx +9 -0
  38. package/src/index.ts +5 -0
  39. package/src/lib/README.md +1 -0
  40. package/src/lib/cn.ts +2 -0
  41. package/src/lib/motion.ts +15 -0
  42. package/src/styles/theme.css +43 -0
  43. package/src/styles/utilities.css +2 -1
@@ -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
+ }
@@ -25,6 +25,9 @@ export const choiceBox =
25
25
  export const choiceFrame =
26
26
  "relative inline-flex size-5 items-center justify-center";
27
27
 
28
- /** 压在方格 / 圆正中的记号,默认透明,由 peer 的状态点亮 */
28
+ /**
29
+ * 压在方格 / 圆正中的记号,默认透明,由 peer 的状态点亮。
30
+ * 淡入淡出,和方格换底色的那一下同时走——底色有过渡而记号硬切,看起来是两件事
31
+ */
29
32
  export const choiceMark =
30
- "pointer-events-none absolute inset-0 m-auto text-accent-ink-inverse opacity-0 peer-disabled:text-ink-disabled";
33
+ "pointer-events-none absolute inset-0 m-auto text-accent-ink-inverse opacity-0 transition-opacity duration-(--duration-fast) ease-standard peer-disabled:text-ink-disabled";
@@ -20,6 +20,7 @@ const sizeClass: Record<CornerBracketsSize, string> = {
20
20
  * 用于矩阵里的单选(物品格、名册卡、图鉴);列表行用左缘色条,页签用底色,都不用它。
21
21
  *
22
22
  * 括号画在盒子之外 4px:它所在的容器不能贴着盒子裁切。
23
+ * 出现时八段短线从角的顶点伸到满长(200ms),消失是直接消失。
23
24
  * 括号只是给眼睛看的,选中态另外要有 `aria-selected` / `aria-pressed`。
24
25
  */
25
26
  export function CornerBrackets({
@@ -34,7 +35,7 @@ export function CornerBrackets({
34
35
  data-visible={visible ? "" : undefined}
35
36
  className={cn(
36
37
  "relative",
37
- visible && "corner-brackets",
38
+ visible && "corner-brackets after:animate-bracket-in",
38
39
  sizeClass[size],
39
40
  className,
40
41
  )}
@@ -116,8 +116,10 @@ export function Dialog({
116
116
  className={cn(
117
117
  overlaySurface,
118
118
  "relative flex max-h-full min-h-0 w-full flex-col",
119
- "transition-[scale,opacity] duration-(--duration-base) ease-exit",
120
- "data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
119
+ // 进场:淡入(200ms),同时从下面升上来 8px(300ms)。退场只淡出——位移只写在起点上。
120
+ // 不放大:这套语言里没有东西是"变大着出来"的
121
+ "transition-[opacity,translate] duration-[var(--duration-fast),var(--duration-base)] ease-[var(--ease-standard),var(--ease-exit)]",
122
+ "data-ending-style:opacity-0 data-starting-style:translate-y-(--motion-shift-lg) data-starting-style:opacity-0",
121
123
  sizeClass[size],
122
124
  className,
123
125
  )}
@@ -1,5 +1,6 @@
1
1
  import { createContext } from "react";
2
2
  import { focusRingInset } from "../../lib/focus-ring";
3
+ import { enterFromSide } from "../../lib/motion";
3
4
 
4
5
  /*
5
6
  * 下拉菜单与下拉选择共用的面板和选项。
@@ -12,11 +13,17 @@ export type MenuVariant = "plain" | "strong";
12
13
  /** 选项要知道自己在哪种面板里,才知道"当前项"怎么画 */
13
14
  export const MenuVariantContext = createContext<MenuVariant>("plain");
14
15
 
15
- /** 面板:直角、1px 线、环境阴影;顶部一条半宽的行动色细条,展开时从 0 伸到一半宽 */
16
+ /**
17
+ * 面板:直角、1px 线、环境阴影;顶部一条半宽的行动色细条,展开时从 0 伸到一半宽。
18
+ *
19
+ * 进场:淡入(200ms),同时从触发处那一侧挪过来 4px(300ms,和细条同一拍)。
20
+ * 退场只淡出——位移只写在起点上。两个时长、两条缓动按属性一一对应
21
+ */
16
22
  export const menuPanel = [
17
23
  "relative min-w-40 border border-line bg-surface-raised py-1 text-ink shadow-sm outline-none",
18
- "origin-(--transform-origin) transition-opacity duration-(--duration-fast) ease-standard",
24
+ "origin-(--transform-origin) transition-[opacity,translate] duration-[var(--duration-fast),var(--duration-base)] ease-[var(--ease-standard),var(--ease-exit)]",
19
25
  "data-ending-style:opacity-0 data-starting-style:opacity-0",
26
+ enterFromSide,
20
27
  "before:pointer-events-none before:absolute before:-top-px before:-left-px before:h-[3px] before:w-1/2 before:origin-left before:bg-action before:content-['']",
21
28
  "before:transition-transform before:duration-(--duration-base) before:ease-exit data-starting-style:before:scale-x-0",
22
29
  "forced-colors:before:hidden",
@@ -348,7 +348,7 @@ export function FileUpload({
348
348
  data-brackets=""
349
349
  className={cn(
350
350
  decor,
351
- "corner-brackets absolute inset-1.5 [--bracket-offset:0px]",
351
+ "corner-brackets absolute inset-1.5 [--bracket-offset:0px] after:animate-bracket-in",
352
352
  )}
353
353
  />
354
354
  )}
@@ -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";