@endfield-ui/react 0.0.0-stage → 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.
Files changed (240) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +27 -0
  3. package/README.md +818 -2
  4. package/dist/index.d.ts +3445 -0
  5. package/dist/index.js +8706 -0
  6. package/dist/styles.css +2 -0
  7. package/package.json +83 -3
  8. package/src/components/README.md +179 -0
  9. package/src/components/accordion/Accordion.tsx +168 -0
  10. package/src/components/accordion/index.ts +8 -0
  11. package/src/components/alert/Alert.tsx +106 -0
  12. package/src/components/alert/index.ts +1 -0
  13. package/src/components/avatar/Avatar.tsx +92 -0
  14. package/src/components/avatar/AvatarSwitcher.tsx +261 -0
  15. package/src/components/avatar/index.ts +8 -0
  16. package/src/components/back-to-top/BackToTop.tsx +124 -0
  17. package/src/components/back-to-top/index.ts +5 -0
  18. package/src/components/badge/Badge.tsx +75 -0
  19. package/src/components/badge/index.ts +1 -0
  20. package/src/components/bracket-title/BracketTitle.tsx +40 -0
  21. package/src/components/bracket-title/index.ts +1 -0
  22. package/src/components/breadcrumb/Breadcrumb.tsx +161 -0
  23. package/src/components/breadcrumb/index.ts +6 -0
  24. package/src/components/button/Button.tsx +357 -0
  25. package/src/components/button/ButtonGroup.tsx +55 -0
  26. package/src/components/button/index.ts +13 -0
  27. package/src/components/calendar/Calendar.tsx +443 -0
  28. package/src/components/calendar/index.ts +7 -0
  29. package/src/components/carousel/Carousel.tsx +252 -0
  30. package/src/components/carousel/index.ts +7 -0
  31. package/src/components/carousel/snap-track.ts +74 -0
  32. package/src/components/checkbox/Checkbox.tsx +94 -0
  33. package/src/components/checkbox/choice-style.ts +30 -0
  34. package/src/components/checkbox/index.ts +1 -0
  35. package/src/components/chip/FilterChip.tsx +68 -0
  36. package/src/components/chip/ResourceChip.tsx +63 -0
  37. package/src/components/chip/capsule-style.ts +22 -0
  38. package/src/components/chip/index.ts +6 -0
  39. package/src/components/combobox/Combobox.tsx +450 -0
  40. package/src/components/combobox/index.ts +10 -0
  41. package/src/components/completion-banner/CompletionBanner.tsx +72 -0
  42. package/src/components/completion-banner/index.ts +4 -0
  43. package/src/components/context-menu/ContextMenu.tsx +70 -0
  44. package/src/components/context-menu/index.ts +5 -0
  45. package/src/components/corner-brackets/CornerBrackets.tsx +43 -0
  46. package/src/components/corner-brackets/index.ts +5 -0
  47. package/src/components/countdown/Countdown.tsx +145 -0
  48. package/src/components/countdown/index.ts +6 -0
  49. package/src/components/dash-indicator/DashIndicator.tsx +44 -0
  50. package/src/components/dash-indicator/index.ts +1 -0
  51. package/src/components/data-row/DataRow.tsx +282 -0
  52. package/src/components/data-row/index.ts +8 -0
  53. package/src/components/date-picker/DateField.tsx +159 -0
  54. package/src/components/date-picker/DatePicker.tsx +182 -0
  55. package/src/components/date-picker/DateRangePicker.tsx +203 -0
  56. package/src/components/date-picker/index.ts +7 -0
  57. package/src/components/dialog/Dialog.tsx +203 -0
  58. package/src/components/dialog/index.ts +7 -0
  59. package/src/components/dialog/initial-focus.ts +19 -0
  60. package/src/components/dialog/overlay-style.ts +28 -0
  61. package/src/components/drawer/Drawer.tsx +213 -0
  62. package/src/components/drawer/index.ts +6 -0
  63. package/src/components/dropdown-menu/DropdownMenu.tsx +427 -0
  64. package/src/components/dropdown-menu/MenuCheck.tsx +44 -0
  65. package/src/components/dropdown-menu/index.ts +22 -0
  66. package/src/components/dropdown-menu/menu-style.ts +47 -0
  67. package/src/components/empty-state/EmptyState.tsx +79 -0
  68. package/src/components/empty-state/index.ts +1 -0
  69. package/src/components/field/Field.tsx +203 -0
  70. package/src/components/field/index.ts +6 -0
  71. package/src/components/file-upload/FileItem.tsx +201 -0
  72. package/src/components/file-upload/FileUpload.tsx +425 -0
  73. package/src/components/file-upload/index.ts +9 -0
  74. package/src/components/flyout-bar/FlyoutBar.tsx +158 -0
  75. package/src/components/flyout-bar/index.ts +7 -0
  76. package/src/components/ghost-text/GhostText.tsx +47 -0
  77. package/src/components/ghost-text/index.ts +5 -0
  78. package/src/components/hatch/Hatch.tsx +35 -0
  79. package/src/components/hatch/index.ts +1 -0
  80. package/src/components/hazard-stripe/HazardStripe.tsx +35 -0
  81. package/src/components/hazard-stripe/index.ts +5 -0
  82. package/src/components/hover-card/HoverCard.tsx +101 -0
  83. package/src/components/hover-card/index.ts +6 -0
  84. package/src/components/icon-button/IconButton.tsx +95 -0
  85. package/src/components/icon-button/index.ts +6 -0
  86. package/src/components/image-viewer/ImageViewer.tsx +427 -0
  87. package/src/components/image-viewer/index.ts +7 -0
  88. package/src/components/input/Input.tsx +109 -0
  89. package/src/components/input/control-box.ts +71 -0
  90. package/src/components/input/index.ts +6 -0
  91. package/src/components/input/value-chip.ts +34 -0
  92. package/src/components/item-slot/ItemGrid.tsx +207 -0
  93. package/src/components/item-slot/ItemSlot.tsx +284 -0
  94. package/src/components/item-slot/index.ts +7 -0
  95. package/src/components/kbd/Kbd.tsx +33 -0
  96. package/src/components/kbd/index.ts +1 -0
  97. package/src/components/link-element/LinkElement.tsx +25 -0
  98. package/src/components/list/List.tsx +229 -0
  99. package/src/components/list/band-style.ts +18 -0
  100. package/src/components/list/index.ts +8 -0
  101. package/src/components/loader/Loader.tsx +154 -0
  102. package/src/components/loader/index.ts +1 -0
  103. package/src/components/marquee/Marquee.tsx +96 -0
  104. package/src/components/marquee/index.ts +1 -0
  105. package/src/components/media-card/MediaCard.tsx +139 -0
  106. package/src/components/media-card/index.ts +5 -0
  107. package/src/components/nav-action/NavAction.tsx +142 -0
  108. package/src/components/nav-action/index.ts +5 -0
  109. package/src/components/nav-menu/NavMenu.tsx +288 -0
  110. package/src/components/nav-menu/index.ts +6 -0
  111. package/src/components/navigator/Navigator.tsx +136 -0
  112. package/src/components/navigator/index.ts +5 -0
  113. package/src/components/otp-input/OtpInput.tsx +211 -0
  114. package/src/components/otp-input/index.ts +7 -0
  115. package/src/components/page-header/PageHeader.tsx +182 -0
  116. package/src/components/page-header/index.ts +6 -0
  117. package/src/components/pagination/Pagination.tsx +181 -0
  118. package/src/components/pagination/index.ts +5 -0
  119. package/src/components/panel/Panel.tsx +114 -0
  120. package/src/components/panel/index.ts +13 -0
  121. package/src/components/play-button/PlayButton.tsx +92 -0
  122. package/src/components/play-button/index.ts +7 -0
  123. package/src/components/popover/Popover.tsx +97 -0
  124. package/src/components/popover/index.ts +8 -0
  125. package/src/components/popover/panel-style.ts +15 -0
  126. package/src/components/progress/Progress.tsx +133 -0
  127. package/src/components/progress/ProgressRing.tsx +123 -0
  128. package/src/components/progress/index.ts +2 -0
  129. package/src/components/radio/Radio.tsx +182 -0
  130. package/src/components/radio/index.ts +7 -0
  131. package/src/components/rec-indicator/RecIndicator.tsx +47 -0
  132. package/src/components/rec-indicator/index.ts +1 -0
  133. package/src/components/registration-strip/RegistrationStrip.tsx +51 -0
  134. package/src/components/registration-strip/index.ts +5 -0
  135. package/src/components/schedule/Schedule.tsx +457 -0
  136. package/src/components/schedule/index.ts +9 -0
  137. package/src/components/scroll-area/ScrollArea.tsx +137 -0
  138. package/src/components/scroll-area/index.ts +5 -0
  139. package/src/components/scroll-hint/ScrollHint.tsx +78 -0
  140. package/src/components/scroll-hint/index.ts +5 -0
  141. package/src/components/section-title/SectionTitle.tsx +202 -0
  142. package/src/components/section-title/index.ts +5 -0
  143. package/src/components/segmented-control/SegmentedControl.tsx +220 -0
  144. package/src/components/segmented-control/index.ts +8 -0
  145. package/src/components/select/Select.tsx +390 -0
  146. package/src/components/select/index.ts +15 -0
  147. package/src/components/side-rail/SideRail.tsx +317 -0
  148. package/src/components/side-rail/SideRailSub.tsx +298 -0
  149. package/src/components/side-rail/index.ts +14 -0
  150. package/src/components/skeleton/Skeleton.tsx +70 -0
  151. package/src/components/skeleton/index.ts +1 -0
  152. package/src/components/slider/Slider.tsx +219 -0
  153. package/src/components/slider/index.ts +8 -0
  154. package/src/components/sparkline/Sparkline.tsx +122 -0
  155. package/src/components/sparkline/index.ts +6 -0
  156. package/src/components/spinner/Spinner.tsx +64 -0
  157. package/src/components/spinner/index.ts +1 -0
  158. package/src/components/stat/Stat.tsx +79 -0
  159. package/src/components/stat/index.ts +1 -0
  160. package/src/components/stepper/Stepper.tsx +316 -0
  161. package/src/components/stepper/index.ts +1 -0
  162. package/src/components/steps/Steps.tsx +302 -0
  163. package/src/components/steps/index.ts +10 -0
  164. package/src/components/switch/Switch.tsx +120 -0
  165. package/src/components/switch/index.ts +1 -0
  166. package/src/components/table/Table.tsx +560 -0
  167. package/src/components/table/index.ts +20 -0
  168. package/src/components/tabs/Tabs.tsx +313 -0
  169. package/src/components/tabs/index.ts +12 -0
  170. package/src/components/tag/Tag.tsx +66 -0
  171. package/src/components/tag/TagPair.tsx +34 -0
  172. package/src/components/tag/index.ts +2 -0
  173. package/src/components/tag-input/TagInput.tsx +502 -0
  174. package/src/components/tag-input/index.ts +8 -0
  175. package/src/components/term/Term.tsx +53 -0
  176. package/src/components/term/index.ts +1 -0
  177. package/src/components/textarea/Textarea.tsx +99 -0
  178. package/src/components/textarea/index.ts +1 -0
  179. package/src/components/texture/Texture.tsx +45 -0
  180. package/src/components/texture/index.ts +1 -0
  181. package/src/components/tick-ring/TickRing.tsx +77 -0
  182. package/src/components/tick-ring/index.ts +1 -0
  183. package/src/components/timeline/Timeline.tsx +84 -0
  184. package/src/components/timeline/index.ts +7 -0
  185. package/src/components/toast/Toast.tsx +257 -0
  186. package/src/components/toast/index.ts +10 -0
  187. package/src/components/toc/Toc.tsx +263 -0
  188. package/src/components/toc/index.ts +7 -0
  189. package/src/components/toolbar/Toolbar.tsx +389 -0
  190. package/src/components/toolbar/index.ts +17 -0
  191. package/src/components/tooltip/Tooltip.tsx +175 -0
  192. package/src/components/tooltip/index.ts +8 -0
  193. package/src/components/top-bar/TopBar.tsx +47 -0
  194. package/src/components/top-bar/index.ts +1 -0
  195. package/src/components/viewfinder/Viewfinder.tsx +97 -0
  196. package/src/components/viewfinder/index.ts +6 -0
  197. package/src/hooks/README.md +14 -0
  198. package/src/hooks/index.ts +4 -0
  199. package/src/hooks/useControllableState.ts +30 -0
  200. package/src/hooks/useInView.ts +44 -0
  201. package/src/hooks/useOverflowing.ts +28 -0
  202. package/src/hooks/usePortalScope.ts +68 -0
  203. package/src/hooks/useScrollPosition.ts +57 -0
  204. package/src/hooks/useTheme.ts +102 -0
  205. package/src/icons/ArrowCorner.tsx +7 -0
  206. package/src/icons/ArrowLeft.tsx +6 -0
  207. package/src/icons/ArrowRight.tsx +6 -0
  208. package/src/icons/CalendarIcon.tsx +10 -0
  209. package/src/icons/Check.tsx +4 -0
  210. package/src/icons/ChevronDown.tsx +3 -0
  211. package/src/icons/ChevronLeft.tsx +6 -0
  212. package/src/icons/ChevronRight.tsx +6 -0
  213. package/src/icons/Close.tsx +3 -0
  214. package/src/icons/Crosshair.tsx +7 -0
  215. package/src/icons/Lock.tsx +6 -0
  216. package/src/icons/Menu.tsx +4 -0
  217. package/src/icons/Minus.tsx +4 -0
  218. package/src/icons/Plus.tsx +3 -0
  219. package/src/icons/README.md +31 -0
  220. package/src/icons/StatusDanger.tsx +7 -0
  221. package/src/icons/StatusInfo.tsx +7 -0
  222. package/src/icons/StatusSuccess.tsx +10 -0
  223. package/src/icons/StatusWarning.tsx +7 -0
  224. package/src/icons/TriangleRight.tsx +7 -0
  225. package/src/icons/Upload.tsx +7 -0
  226. package/src/icons/createIcon.tsx +34 -0
  227. package/src/icons/index.ts +21 -0
  228. package/src/index.ts +87 -0
  229. package/src/lib/README.md +17 -0
  230. package/src/lib/cn.ts +34 -0
  231. package/src/lib/date.ts +125 -0
  232. package/src/lib/decor.ts +6 -0
  233. package/src/lib/defined.ts +9 -0
  234. package/src/lib/file.ts +61 -0
  235. package/src/lib/focus-ring.ts +11 -0
  236. package/src/lib/merge-refs.ts +11 -0
  237. package/src/styles/README.md +70 -0
  238. package/src/styles/index.css +25 -0
  239. package/src/styles/theme.css +568 -0
  240. package/src/styles/utilities.css +241 -0
@@ -0,0 +1,175 @@
1
+ import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
2
+ import {
3
+ cloneElement,
4
+ createContext,
5
+ useContext,
6
+ useId,
7
+ useSyncExternalStore,
8
+ type ReactElement,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { createPortal } from "react-dom";
12
+ import { usePortalScope } from "../../hooks/usePortalScope";
13
+ import { cn } from "../../lib/cn";
14
+
15
+ export type TooltipSide = "top" | "bottom" | "left" | "right";
16
+ export type TooltipAlign = "start" | "center" | "end";
17
+
18
+ /* 规范:出现前约 300ms,消失不延迟 */
19
+ const DEFAULT_DELAY = 300;
20
+
21
+ const InProviderContext = createContext(false);
22
+
23
+ export type TooltipProviderProps = {
24
+ /** 这一组提示统一的延迟,毫秒 */
25
+ delay?: number;
26
+ /** 一个提示消失后多久之内,旁边的提示不用再等延迟,毫秒。默认 400 */
27
+ timeout?: number;
28
+ children: ReactNode;
29
+ };
30
+
31
+ /**
32
+ * 可选。包住一组相邻的提示(工具栏):第一个出现之后,移到旁边的不用再等延迟。
33
+ */
34
+ export function TooltipProvider({
35
+ delay = DEFAULT_DELAY,
36
+ timeout,
37
+ children,
38
+ }: TooltipProviderProps) {
39
+ return (
40
+ <BaseTooltip.Provider delay={delay} timeout={timeout}>
41
+ <InProviderContext value={true}>{children}</InProviderContext>
42
+ </BaseTooltip.Provider>
43
+ );
44
+ }
45
+
46
+ export type TooltipProps = {
47
+ /** 提示的内容:一句话,不放交互元素 */
48
+ content: ReactNode;
49
+ /** 触发元素:一个能接收 `ref` 与事件的元素(按钮、链接) */
50
+ children: ReactElement<Record<string, unknown>>;
51
+ /** 出现在触发元素的哪一侧,放不下时自动翻到对面。默认 `top` */
52
+ side?: TooltipSide;
53
+ align?: TooltipAlign;
54
+ /**
55
+ * 悬停多久后出现,毫秒,默认 300。键盘聚焦时立刻出现;消失不延迟。
56
+ * 在 `TooltipProvider` 里不传时跟随它的 `delay`。
57
+ */
58
+ delay?: number;
59
+ /** 指向触发元素的小三角,默认有。关掉后提示贴着触发元素 */
60
+ arrow?: boolean;
61
+ open?: boolean;
62
+ defaultOpen?: boolean;
63
+ onOpenChange?: (open: boolean) => void;
64
+ /** 不再出现 */
65
+ disabled?: boolean;
66
+ /** 给提示本身 */
67
+ className?: string;
68
+ };
69
+
70
+ const subscribeNever = () => () => {};
71
+
72
+ /** 常驻的隐藏文字,给 `aria-describedby` 用。挂在 `<body>` 下,不影响触发元素周围的排版 */
73
+ function Description({ id, children }: { id: string; children: ReactNode }) {
74
+ // 服务端与水合时没有 document,挂载后才渲染
75
+ const mounted = useSyncExternalStore(
76
+ subscribeNever,
77
+ () => true,
78
+ () => false,
79
+ );
80
+ if (!mounted) return null;
81
+ return createPortal(
82
+ <span id={id} hidden>
83
+ {children}
84
+ </span>,
85
+ document.body,
86
+ );
87
+ }
88
+
89
+ /**
90
+ * 悬停或键盘聚焦时出现的一句说明。触屏上不出现:必要的信息不要只放在这里。
91
+ *
92
+ * 触发元素要有自己的可访问名称(图标按钮的 `aria-label`)。提示的内容会作为
93
+ * 补充说明关联给读屏;它和 `aria-label` 一字不差时不再重复关联。
94
+ */
95
+ export function Tooltip({
96
+ content,
97
+ children,
98
+ side = "top",
99
+ align = "center",
100
+ delay,
101
+ arrow = true,
102
+ open,
103
+ defaultOpen,
104
+ onOpenChange,
105
+ disabled = false,
106
+ className,
107
+ }: TooltipProps) {
108
+ const descriptionId = useId();
109
+ const inProvider = useContext(InProviderContext);
110
+ // 提示是一块和页面相反的颜色。做成相反主题的局部主题,而不是直接用反转色:
111
+ // 里面的键位提示、着色词因此也按这块底色取值,不会和底融在一起
112
+ const { anchorRef, portalRef } = usePortalScope({ invertTheme: true });
113
+
114
+ const label = children.props["aria-label"];
115
+ const repeatsLabel =
116
+ typeof content === "string" &&
117
+ typeof label === "string" &&
118
+ content.trim() === label.trim();
119
+ const described = !disabled && !repeatsLabel;
120
+
121
+ const existing = children.props["aria-describedby"];
122
+ const trigger = described
123
+ ? cloneElement(children, {
124
+ "aria-describedby":
125
+ typeof existing === "string"
126
+ ? `${existing} ${descriptionId}`
127
+ : descriptionId,
128
+ })
129
+ : children;
130
+
131
+ return (
132
+ <BaseTooltip.Root
133
+ open={open}
134
+ defaultOpen={defaultOpen}
135
+ onOpenChange={onOpenChange && ((next) => onOpenChange(next))}
136
+ disabled={disabled}
137
+ >
138
+ <BaseTooltip.Trigger
139
+ ref={anchorRef}
140
+ render={trigger}
141
+ delay={delay ?? (inProvider ? undefined : DEFAULT_DELAY)}
142
+ />
143
+ {described && <Description id={descriptionId}>{content}</Description>}
144
+ <BaseTooltip.Portal ref={portalRef}>
145
+ <BaseTooltip.Positioner
146
+ side={side}
147
+ align={align}
148
+ sideOffset={arrow ? 8 : 2}
149
+ className="z-(--z-overlay)"
150
+ >
151
+ <BaseTooltip.Popup
152
+ className={cn(
153
+ // 透明的边线:高对比模式下背景色被系统覆盖,边线会自己显形
154
+ "relative max-w-64 border border-transparent bg-surface-raised px-2.5 py-1.5 text-sm wrap-anywhere text-ink",
155
+ "origin-(--transform-origin) transition-opacity duration-(--duration-fast) ease-standard",
156
+ "data-ending-style:opacity-0 data-instant:transition-none data-starting-style:opacity-0",
157
+ className,
158
+ )}
159
+ >
160
+ {arrow && (
161
+ <BaseTooltip.Arrow
162
+ aria-hidden="true"
163
+ className={cn(
164
+ "size-2 rotate-45 bg-surface-raised forced-colors:hidden",
165
+ "data-[side=bottom]:-top-1 data-[side=left]:-right-1 data-[side=right]:-left-1 data-[side=top]:-bottom-1",
166
+ )}
167
+ />
168
+ )}
169
+ {content}
170
+ </BaseTooltip.Popup>
171
+ </BaseTooltip.Positioner>
172
+ </BaseTooltip.Portal>
173
+ </BaseTooltip.Root>
174
+ );
175
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ Tooltip,
3
+ TooltipProvider,
4
+ type TooltipAlign,
5
+ type TooltipProps,
6
+ type TooltipProviderProps,
7
+ type TooltipSide,
8
+ } from "./Tooltip";
@@ -0,0 +1,47 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { NavActionContext } from "../nav-action/NavAction";
4
+
5
+ export type TopBarProps = ComponentProps<"header"> & {
6
+ /** 最左边的标志。本库不带标志,传你自己的 */
7
+ brand?: ReactNode;
8
+ /** 工具图标钮:几个 `IconButton` */
9
+ tools?: ReactNode;
10
+ /** 主行动:放一个 `NavAction` */
11
+ action?: ReactNode;
12
+ /** 最右边的菜单钮:放一个 `NavMenu`(它自己带着触发按钮) */
13
+ menu?: ReactNode;
14
+ };
15
+
16
+ /**
17
+ * 顶栏:窄屏上的主导航。从左到右是标志、空白、工具图标钮、主行动、菜单钮;
18
+ * 栏目本身不排在这一条里,收进 `NavMenu`。
19
+ *
20
+ * 默认 `sticky` 贴在顶上。它只管排版,各格放什么由使用方给。
21
+ */
22
+ export function TopBar({
23
+ brand,
24
+ tools,
25
+ action,
26
+ menu,
27
+ className,
28
+ children,
29
+ ...props
30
+ }: TopBarProps) {
31
+ return (
32
+ <header
33
+ {...props}
34
+ className={cn(
35
+ // 环境阴影在暗色页面上看不出来,所以另有一条边线
36
+ "sticky top-0 z-(--z-nav) flex h-14 shrink-0 items-center gap-2 border-b border-line bg-surface px-4 text-ink shadow-lg",
37
+ className,
38
+ )}
39
+ >
40
+ {brand && <div className="flex min-w-0 items-center">{brand}</div>}
41
+ <div className="min-w-0 flex-1">{children}</div>
42
+ {tools && <div className="flex shrink-0 items-center gap-1">{tools}</div>}
43
+ {action && <NavActionContext value="inline">{action}</NavActionContext>}
44
+ {menu}
45
+ </header>
46
+ );
47
+ }
@@ -0,0 +1 @@
1
+ export { TopBar, type TopBarProps } from "./TopBar";
@@ -0,0 +1,97 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { decor } from "../../lib/decor";
4
+
5
+ export type ViewfinderSize = "sm" | "md";
6
+
7
+ export type ViewfinderReadouts = {
8
+ topLeft?: ReactNode;
9
+ topRight?: ReactNode;
10
+ bottomLeft?: ReactNode;
11
+ bottomRight?: ReactNode;
12
+ };
13
+
14
+ export type ViewfinderProps = ComponentProps<"div"> & {
15
+ /** 取景角的臂长:16px 或 24px */
16
+ size?: ViewfinderSize;
17
+ /** 正中放一个小准星,标出焦点 */
18
+ crosshair?: boolean;
19
+ /**
20
+ * 四个角上的读数:坐标、比例、版本、时间码。
21
+ * 必须是真实的数据;没有就不放。
22
+ */
23
+ readouts?: ViewfinderReadouts;
24
+ };
25
+
26
+ const armClass: Record<ViewfinderSize, string> = {
27
+ sm: "[--bracket-arm:16px]",
28
+ md: "[--bracket-arm:24px]",
29
+ };
30
+
31
+ const readoutPosition: Record<keyof ViewfinderReadouts, string> = {
32
+ topLeft: "top-5 left-5",
33
+ topRight: "top-5 right-5 text-right",
34
+ bottomLeft: "bottom-5 left-5",
35
+ bottomRight: "right-5 bottom-5 text-right",
36
+ };
37
+
38
+ /**
39
+ * 取景角:框住一张图、一片地图区域、一个焦点,标的是"范围",不是选中。
40
+ * 里面放被框住的内容;压在深色画面上时给它加 `data-theme="dark"`。
41
+ *
42
+ * 只用在展示性的画面上,一屏一处。取景角与准星是装饰,读数是真实信息、读屏读得到。
43
+ */
44
+ export function Viewfinder({
45
+ size = "sm",
46
+ crosshair = false,
47
+ readouts,
48
+ className,
49
+ children,
50
+ ...props
51
+ }: ViewfinderProps) {
52
+ return (
53
+ <div {...props} className={cn("relative text-ink", className)}>
54
+ {children}
55
+ <span
56
+ aria-hidden="true"
57
+ className={cn(
58
+ decor,
59
+ "corner-brackets absolute inset-3 [--bracket-color:currentColor] [--bracket-offset:0px] [--bracket-width:1.5px]",
60
+ armClass[size],
61
+ )}
62
+ />
63
+ {crosshair && (
64
+ <svg
65
+ aria-hidden="true"
66
+ focusable="false"
67
+ viewBox="0 0 12 12"
68
+ fill="none"
69
+ stroke="currentColor"
70
+ strokeWidth={1.5}
71
+ className={cn(
72
+ decor,
73
+ "absolute top-1/2 left-1/2 size-3 -translate-1/2 text-accent-ink",
74
+ )}
75
+ >
76
+ <path d="M6 0v12M0 6h12" />
77
+ </svg>
78
+ )}
79
+ {readouts &&
80
+ (Object.keys(readoutPosition) as (keyof ViewfinderReadouts)[]).map(
81
+ (corner) =>
82
+ readouts[corner] !== undefined && (
83
+ <span
84
+ key={corner}
85
+ data-corner={corner}
86
+ className={cn(
87
+ "pointer-events-none absolute font-tech text-xs leading-none text-ink-secondary tabular-nums",
88
+ readoutPosition[corner],
89
+ )}
90
+ >
91
+ {readouts[corner]}
92
+ </span>
93
+ ),
94
+ )}
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ Viewfinder,
3
+ type ViewfinderProps,
4
+ type ViewfinderReadouts,
5
+ type ViewfinderSize,
6
+ } from "./Viewfinder";
@@ -0,0 +1,14 @@
1
+ # hooks
2
+
3
+ 只放不依赖具体组件的逻辑。只服务于单个组件的 hook 放在该组件目录内。
4
+
5
+ | hook | 作用 |
6
+ | --- | --- |
7
+ | `useTheme` | 读写 `<html data-theme>`;支持 `light` / `dark` / `system`,选择记在 `localStorage` |
8
+ | `useControllableState` | 受控 / 非受控状态合并;值没变时不触发 `onChange` |
9
+ | `useInView` | 元素第一次进入视口后返回 `true`,之后不再变回去——入场动画只播一次 |
10
+ | `useOverflowing` | 容器里的内容是不是比容器宽、比容器高。给"只有真的能滚动时才是一个能聚焦的区域"用(表格、排期)。不对外导出 |
11
+ | `useScrollPosition` | 盯着一个滚动容器(不传就是整个页面),由滚动位置算出一个值:滚动、窗口尺寸变了、内容晚到都会重新算,值没变不重新渲染。滚动提示和回到顶部共用。不对外导出 |
12
+ | `usePortalScope` | 浮层挂到 `<body>` 下之后拿不到局部容器上的 `data-theme` / `data-choice`:返回 `anchorRef`(给触发元素)和 `portalRef`(给浮层的容器),容器挂载时把最近的开关抄过去。`invertTheme` 抄相反的主题(文字提示)。最近的是反转块(`data-theme="inverse"`)时,抄的是它实际算出来的亮或暗 |
13
+
14
+ 还没有 `useReducedMotion`:目前的动效都是 CSS 动画与过渡,由 [theme.css](../styles/theme.css) 末尾的全局规则统一降级。等出现用 JS 驱动的动效时再加。
@@ -0,0 +1,4 @@
1
+ export { useControllableState } from "./useControllableState";
2
+ export { useInView } from "./useInView";
3
+ export { useTheme, type Theme, type ThemePreference } from "./useTheme";
4
+ export { usePortalScope } from "./usePortalScope";
@@ -0,0 +1,30 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ type Options<T> = {
4
+ /** 传入即为受控 */
5
+ value?: T;
6
+ defaultValue: T;
7
+ onChange?: (value: T) => void;
8
+ };
9
+
10
+ /** 合并受控与非受控两种用法;值没有变化时不触发 `onChange`。 */
11
+ export function useControllableState<T>({
12
+ value,
13
+ defaultValue,
14
+ onChange,
15
+ }: Options<T>): [T, (next: T) => void] {
16
+ const [inner, setInner] = useState(defaultValue);
17
+ const controlled = value !== undefined;
18
+ const current = controlled ? value : inner;
19
+
20
+ const set = useCallback(
21
+ (next: T) => {
22
+ if (Object.is(next, current)) return;
23
+ if (!controlled) setInner(next);
24
+ onChange?.(next);
25
+ },
26
+ [controlled, current, onChange],
27
+ );
28
+
29
+ return [current, set];
30
+ }
@@ -0,0 +1,44 @@
1
+ import { useEffect, useRef, useState, type RefObject } from "react";
2
+
3
+ type Options = {
4
+ /** 为 true 时不观察,始终返回 false */
5
+ disabled?: boolean;
6
+ rootMargin?: string;
7
+ threshold?: number;
8
+ };
9
+
10
+ /**
11
+ * 元素第一次进入视口后返回 true,之后不再变回 false——入场动画只播一次。
12
+ */
13
+ export function useInView<T extends Element = Element>({
14
+ disabled = false,
15
+ rootMargin = "0px",
16
+ threshold = 0,
17
+ }: Options = {}): [RefObject<T | null>, boolean] {
18
+ const ref = useRef<T>(null);
19
+ const [inView, setInView] = useState(false);
20
+
21
+ useEffect(() => {
22
+ const node = ref.current;
23
+ if (disabled || inView || !node) return;
24
+
25
+ if (typeof IntersectionObserver === "undefined") {
26
+ setInView(true);
27
+ return;
28
+ }
29
+
30
+ const observer = new IntersectionObserver(
31
+ (entries) => {
32
+ if (entries.some((entry) => entry.isIntersecting)) {
33
+ setInView(true);
34
+ observer.disconnect();
35
+ }
36
+ },
37
+ { rootMargin, threshold },
38
+ );
39
+ observer.observe(node);
40
+ return () => observer.disconnect();
41
+ }, [disabled, inView, rootMargin, threshold]);
42
+
43
+ return [ref, inView];
44
+ }
@@ -0,0 +1,28 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ /**
4
+ * 容器里的内容是不是比容器宽、比容器高。
5
+ * 给"只有真的能滚动时才是一个能聚焦的区域"用:表格、排期。
6
+ * 盯着容器和它的第一个子元素的尺寸变化
7
+ */
8
+ export function useOverflowing<T extends HTMLElement = HTMLDivElement>() {
9
+ const ref = useRef<T>(null);
10
+ const [x, setX] = useState(false);
11
+ const [y, setY] = useState(false);
12
+
13
+ useEffect(() => {
14
+ const node = ref.current;
15
+ if (!node || typeof ResizeObserver === "undefined") return;
16
+ const measure = () => {
17
+ setX(node.scrollWidth > node.clientWidth + 1);
18
+ setY(node.scrollHeight > node.clientHeight + 1);
19
+ };
20
+ measure();
21
+ const observer = new ResizeObserver(measure);
22
+ observer.observe(node);
23
+ if (node.firstElementChild) observer.observe(node.firstElementChild);
24
+ return () => observer.disconnect();
25
+ }, []);
26
+
27
+ return [ref, { x, y }] as const;
28
+ }
@@ -0,0 +1,68 @@
1
+ import { useCallback, useRef } from "react";
2
+
3
+ const opposite = (theme: "light" | "dark") =>
4
+ theme === "dark" ? "light" : "dark";
5
+
6
+ type PortalScopeOptions = {
7
+ /**
8
+ * 浮层用和声明处相反的主题:亮色页面上是一块深色,暗色页面上是一块浅色
9
+ * (文字提示)。里面的控件因此也按相反的底色取值。
10
+ */
11
+ invertTheme?: boolean;
12
+ };
13
+
14
+ /**
15
+ * 浮层挂在 `<body>` 下,离开了声明它的那棵子树,拿不到局部容器上的
16
+ * `data-theme` / `data-choice`。把 `anchorRef` 交给触发元素、`portalRef`
17
+ * 交给浮层的容器:容器每次挂载时,从触发元素往上找最近的开关,抄到容器上。
18
+ *
19
+ * 最近的就是 `<html>` 时不抄——浮层本来就继承它,抄了反而会在切换主题后留下旧值。
20
+ * 没有触发元素(纯受控的弹窗)时什么都不做,浮层跟随 `<html>`。
21
+ *
22
+ * 最近的是 `data-theme="inverse"`(反转块)时不能照抄:`inverse` 是相对的,
23
+ * 到了 `<body>` 下它反的就成了页面。所以先算出它实际是亮还是暗,抄算出来的那个。
24
+ */
25
+ export function usePortalScope({
26
+ invertTheme = false,
27
+ }: PortalScopeOptions = {}) {
28
+ const anchor = useRef<Element | null>(null);
29
+
30
+ // 两个都是回调 ref:不管对方要的是哪种元素的 ref 都能接上
31
+ const anchorRef = useCallback((node: Element | null) => {
32
+ anchor.current = node;
33
+ }, []);
34
+
35
+ const portalRef = useCallback(
36
+ (portal: HTMLElement | null) => {
37
+ if (!portal) return;
38
+ const root = document.documentElement;
39
+
40
+ const themed =
41
+ anchor.current?.closest<HTMLElement>("[data-theme]") ?? null;
42
+ const inverted = themed?.dataset.theme === "inverse";
43
+ // 反转块反的是谁:再往上找第一个写明亮 / 暗的容器
44
+ let base = themed;
45
+ while (base?.dataset.theme === "inverse") {
46
+ base = base.parentElement?.closest<HTMLElement>("[data-theme]") ?? null;
47
+ }
48
+ // 没有任何地方写主题时,页面是默认的亮色
49
+ const baseTheme =
50
+ (base ?? root).dataset.theme === "dark" ? "dark" : "light";
51
+ const theme = inverted ? opposite(baseTheme) : baseTheme;
52
+
53
+ if (invertTheme) {
54
+ portal.dataset.theme = opposite(theme);
55
+ } else if (inverted || (themed && themed !== root)) {
56
+ portal.dataset.theme = theme;
57
+ }
58
+
59
+ const choice = anchor.current?.closest<HTMLElement>("[data-choice]");
60
+ if (choice && choice !== root) {
61
+ portal.dataset.choice = choice.dataset.choice;
62
+ }
63
+ },
64
+ [invertTheme],
65
+ );
66
+
67
+ return { anchorRef, portalRef };
68
+ }
@@ -0,0 +1,57 @@
1
+ import { useEffect, useState, type RefObject } from "react";
2
+
3
+ export type ScrollMetrics = {
4
+ /** 已经往下滚了多少 */
5
+ scrolled: number;
6
+ /** 一共能滚多少:内容的高度减去容器的高度 */
7
+ room: number;
8
+ };
9
+
10
+ /**
11
+ * 盯着一个滚动容器(不传就是整个页面),由滚动位置算出一个值。
12
+ * 滚动、窗口尺寸变了、内容晚到(图片加载完)都会重新算;算出来的值没变就不重新渲染,
13
+ * 所以 `read` 要返回布尔或数字这样的原始值,并且用 `useCallback` 固定住。
14
+ *
15
+ * 指定了容器但它还没挂上时,值停在 `initial`。
16
+ */
17
+ export function useScrollPosition<T>(
18
+ target: RefObject<HTMLElement | null> | undefined,
19
+ read: (metrics: ScrollMetrics) => T,
20
+ initial: T,
21
+ ): T {
22
+ const [value, setValue] = useState(initial);
23
+
24
+ useEffect(() => {
25
+ const element = target ? target.current : null;
26
+ if (target && !element) return;
27
+
28
+ const measure = () => {
29
+ const box = element ?? document.documentElement;
30
+ setValue(
31
+ read({
32
+ scrolled: element ? element.scrollTop : window.scrollY,
33
+ room: box.scrollHeight - box.clientHeight,
34
+ }),
35
+ );
36
+ };
37
+
38
+ measure();
39
+ const source = element ?? window;
40
+ source.addEventListener("scroll", measure, { passive: true });
41
+ window.addEventListener("resize", measure);
42
+
43
+ let observer: ResizeObserver | undefined;
44
+ if (typeof ResizeObserver !== "undefined") {
45
+ observer = new ResizeObserver(measure);
46
+ observer.observe(element ?? document.body);
47
+ }
48
+
49
+ return () => {
50
+ source.removeEventListener("scroll", measure);
51
+ window.removeEventListener("resize", measure);
52
+ observer?.disconnect();
53
+ };
54
+ }, [target, read]);
55
+
56
+ return value;
57
+ }