@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,317 @@
1
+ import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
2
+ import {
3
+ createContext,
4
+ useContext,
5
+ useMemo,
6
+ type ComponentProps,
7
+ type MouseEvent,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { usePortalScope } from "../../hooks/usePortalScope";
11
+ import { cn } from "../../lib/cn";
12
+ import { focusRingInset } from "../../lib/focus-ring";
13
+ import { LinkElement, type LinkRender } from "../link-element/LinkElement";
14
+ import { NavActionContext } from "../nav-action/NavAction";
15
+
16
+ export const SideRailContext = createContext({ collapsed: false });
17
+
18
+ /**
19
+ * 一行栏目的样子:普通栏目和二级的父项共用。
20
+ * `current` 是"显示成当前的样子"(墨色、加粗、左缘的短竖条),不管 aria-current
21
+ */
22
+ export function railItemClass({
23
+ current,
24
+ disabled,
25
+ }: {
26
+ current: boolean;
27
+ disabled: boolean;
28
+ }) {
29
+ return cn(
30
+ // 左内边距 20px + 24px 的图标位:收起成 64px 时图标正好居中,展开收起时它不跑位
31
+ "relative flex h-12 w-full items-center gap-3 pr-4 pl-5 text-left text-sm font-medium",
32
+ "transition-colors duration-(--duration-fast) ease-standard",
33
+ focusRingInset,
34
+ disabled
35
+ ? "cursor-not-allowed text-ink-disabled"
36
+ : [
37
+ current ? "font-bold text-ink" : "text-ink-secondary",
38
+ "hover:bg-surface-muted hover:text-ink",
39
+ // 收起时浮出来的那块文字(或菜单)还开着:这一项保持同样的底,两块连成一条
40
+ "data-popup-open:bg-surface-muted data-popup-open:text-ink",
41
+ ],
42
+ current &&
43
+ "before:absolute before:top-1/2 before:left-0 before:h-5 before:w-1 before:-translate-y-1/2 before:bg-ink before:content-['']",
44
+ );
45
+ }
46
+
47
+ /** 24px 的图标位,里面的图标 20px */
48
+ export function RailIcon({ children }: { children: ReactNode }) {
49
+ return (
50
+ <span
51
+ aria-hidden="true"
52
+ className="flex size-6 shrink-0 items-center justify-center [&_svg]:size-5"
53
+ >
54
+ {children}
55
+ </span>
56
+ );
57
+ }
58
+
59
+ export type SideRailProps = Omit<ComponentProps<"nav">, "aria-label"> & {
60
+ /** 这块导航的名称,比如"主导航" */
61
+ "aria-label": string;
62
+ /**
63
+ * 收起成只有图标的窄轨(64px)。默认是展开的(224px,图标 + 文字)。
64
+ * 栏目少且固定、屏幕空间紧张时才收起
65
+ */
66
+ collapsed?: boolean;
67
+ /** 最上面的标志。本库不带标志,传你自己的 */
68
+ brand?: ReactNode;
69
+ /** 导航项下面的工具组:几个图标按钮 */
70
+ tools?: ReactNode;
71
+ /** 主行动块:放一个 `NavAction` */
72
+ action?: ReactNode;
73
+ /** 最下面的次要入口 */
74
+ footer?: ReactNode;
75
+ /** `SideRailItem`、`SideRailGroup`、`SideRailSub` */
76
+ children: ReactNode;
77
+ };
78
+
79
+ /**
80
+ * 侧轨:桌面端贴在左边的主导航。自上而下是标志、导航项、工具组、主行动块、次要入口;
81
+ * 导航项放不下时在中间那一段里滚动。
82
+ *
83
+ * 默认 `sticky`、占满视口高;窄屏上不要把它缩小,换成 `TopBar` + `NavMenu`。
84
+ */
85
+ export function SideRail({
86
+ collapsed = false,
87
+ brand,
88
+ tools,
89
+ action,
90
+ footer,
91
+ className,
92
+ children,
93
+ ...props
94
+ }: SideRailProps) {
95
+ const context = useMemo(() => ({ collapsed }), [collapsed]);
96
+
97
+ return (
98
+ <SideRailContext value={context}>
99
+ <nav
100
+ {...props}
101
+ data-collapsed={collapsed ? "" : undefined}
102
+ className={cn(
103
+ // 环境阴影在暗色页面上看不出来,所以另有一条边线
104
+ "sticky top-0 z-(--z-nav) flex h-dvh shrink-0 flex-col border-r border-line bg-surface text-ink shadow-rail",
105
+ "transition-[width] duration-(--duration-base) ease-standard",
106
+ collapsed ? "w-16" : "w-56",
107
+ className,
108
+ )}
109
+ >
110
+ {brand && (
111
+ <div
112
+ className={cn(
113
+ "flex h-16 shrink-0 items-center overflow-hidden",
114
+ collapsed ? "justify-center" : "px-5",
115
+ )}
116
+ >
117
+ {brand}
118
+ </div>
119
+ )}
120
+
121
+ <ul className="flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto overscroll-contain py-2">
122
+ {children}
123
+ </ul>
124
+
125
+ {tools && (
126
+ <div
127
+ className={cn(
128
+ "flex shrink-0 gap-1 p-3",
129
+ collapsed ? "flex-col items-center" : "flex-wrap",
130
+ )}
131
+ >
132
+ {tools}
133
+ </div>
134
+ )}
135
+
136
+ {action && (
137
+ <div
138
+ className={cn("flex shrink-0 p-3", collapsed && "justify-center")}
139
+ >
140
+ <NavActionContext value={collapsed ? "stacked" : "block"}>
141
+ {action}
142
+ </NavActionContext>
143
+ </div>
144
+ )}
145
+
146
+ {footer && (
147
+ <div
148
+ className={cn(
149
+ "flex shrink-0 flex-col gap-1 border-t border-line p-3 text-xs text-ink-secondary",
150
+ collapsed && "items-center",
151
+ )}
152
+ >
153
+ {footer}
154
+ </div>
155
+ )}
156
+ </nav>
157
+ </SideRailContext>
158
+ );
159
+ }
160
+
161
+ type ItemOwnProps = {
162
+ /** 图标。收起时只剩它,所以每一项都要有 */
163
+ icon: ReactNode;
164
+ /** 当前所在的栏目:墨色 + 左缘一条短竖条,输出 `aria-current="page"` */
165
+ current?: boolean;
166
+ /** 行尾的补充:计数、角标。收起时不显示 */
167
+ end?: ReactNode;
168
+ disabled?: boolean;
169
+ /** 传了就渲染成链接 */
170
+ href?: string;
171
+ target?: string;
172
+ rel?: string;
173
+ /** 用这个元素代替 `<a>`(路由库的链接组件)。传了就按链接处理 */
174
+ render?: LinkRender;
175
+ /** 没有 `href` 和 `render` 时渲染成按钮 */
176
+ onClick?: (event: MouseEvent<HTMLElement>) => void;
177
+ /** 栏目的名称。收起时看不见,但仍然是这一项的可访问名称 */
178
+ children: ReactNode;
179
+ };
180
+
181
+ export type SideRailItemProps = ItemOwnProps &
182
+ Omit<ComponentProps<"li">, keyof ItemOwnProps>;
183
+
184
+ /** 侧轨里的一个栏目。 */
185
+ export function SideRailItem({
186
+ icon,
187
+ current = false,
188
+ end,
189
+ disabled = false,
190
+ href,
191
+ target,
192
+ rel,
193
+ render,
194
+ onClick,
195
+ children,
196
+ ...props
197
+ }: SideRailItemProps) {
198
+ const { collapsed } = useContext(SideRailContext);
199
+ const { anchorRef, portalRef } = usePortalScope();
200
+ const isLink = href !== undefined || render !== undefined;
201
+
202
+ const classes = railItemClass({ current, disabled });
203
+
204
+ const content = (
205
+ <>
206
+ <RailIcon>{icon}</RailIcon>
207
+ <span className={collapsed ? "sr-only" : "min-w-0 flex-1 truncate"}>
208
+ {children}
209
+ </span>
210
+ {!collapsed && end && (
211
+ <span className="flex shrink-0 items-center">{end}</span>
212
+ )}
213
+ </>
214
+ );
215
+
216
+ const control = isLink ? (
217
+ <LinkElement
218
+ render={render}
219
+ // 禁用的链接去掉 href,保留 link 角色,让读屏仍能读出"不可用"
220
+ href={disabled ? undefined : href}
221
+ role={disabled ? "link" : undefined}
222
+ aria-disabled={disabled || undefined}
223
+ target={target}
224
+ rel={rel}
225
+ aria-current={current ? "page" : undefined}
226
+ onClick={disabled ? (event) => event.preventDefault() : onClick}
227
+ className={classes}
228
+ >
229
+ {content}
230
+ </LinkElement>
231
+ ) : (
232
+ <button
233
+ type="button"
234
+ disabled={disabled}
235
+ aria-current={current ? "page" : undefined}
236
+ onClick={onClick}
237
+ className={classes}
238
+ >
239
+ {content}
240
+ </button>
241
+ );
242
+
243
+ return (
244
+ <li {...props}>
245
+ {collapsed && !disabled ? (
246
+ // 收起时,悬停或键盘聚焦:一块同色的底从这一项向右展开,里面是栏目名。
247
+ // 它挂在 <body> 下,侧轨自己滚动也裁不到它;只给眼睛看,读屏读的是项里的隐藏文字
248
+ <BaseTooltip.Root>
249
+ <BaseTooltip.Trigger
250
+ ref={anchorRef}
251
+ render={control}
252
+ delay={0}
253
+ closeDelay={0}
254
+ />
255
+ <BaseTooltip.Portal ref={portalRef}>
256
+ <BaseTooltip.Positioner
257
+ side="right"
258
+ align="center"
259
+ sideOffset={0}
260
+ className="pointer-events-none z-(--z-overlay)"
261
+ >
262
+ <BaseTooltip.Popup
263
+ aria-hidden="true"
264
+ className={cn(
265
+ "flex h-(--anchor-height) items-center bg-surface-muted pr-5 pl-1 text-sm font-medium whitespace-nowrap text-ink",
266
+ // 从侧轨一侧拉开
267
+ "[clip-path:inset(0)] transition-[clip-path] duration-(--duration-fast) ease-exit data-instant:transition-none",
268
+ "data-ending-style:[clip-path:inset(0_100%_0_0)] data-starting-style:[clip-path:inset(0_100%_0_0)]",
269
+ )}
270
+ >
271
+ {children}
272
+ </BaseTooltip.Popup>
273
+ </BaseTooltip.Positioner>
274
+ </BaseTooltip.Portal>
275
+ </BaseTooltip.Root>
276
+ ) : (
277
+ control
278
+ )}
279
+ </li>
280
+ );
281
+ }
282
+
283
+ export type SideRailGroupProps = Omit<ComponentProps<"li">, "children"> & {
284
+ /** 这一组的小标题。收起时换成一条短线,名称仍然读得到 */
285
+ label: string;
286
+ /** `SideRailItem` */
287
+ children: ReactNode;
288
+ };
289
+
290
+ /** 把几个栏目归成一组。栏目多的时候用。它只是分组;能展开收起的是 `SideRailSub`。 */
291
+ export function SideRailGroup({
292
+ label,
293
+ className,
294
+ children,
295
+ ...props
296
+ }: SideRailGroupProps) {
297
+ const { collapsed } = useContext(SideRailContext);
298
+
299
+ return (
300
+ <li {...props} className={cn("not-first:mt-2", className)}>
301
+ {collapsed ? (
302
+ <div
303
+ aria-hidden="true"
304
+ className="mx-auto mb-2 h-px w-6 bg-line-strong [li:first-child>&]:hidden"
305
+ />
306
+ ) : (
307
+ <div
308
+ aria-hidden="true"
309
+ className="truncate px-5 pt-2 pb-1 font-tech text-xs text-ink-tertiary"
310
+ >
311
+ {`// ${label}`}
312
+ </div>
313
+ )}
314
+ <ul aria-label={label}>{children}</ul>
315
+ </li>
316
+ );
317
+ }
@@ -0,0 +1,298 @@
1
+ import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible";
2
+ import { Menu as BaseMenu } from "@base-ui/react/menu";
3
+ import {
4
+ Children,
5
+ createContext,
6
+ isValidElement,
7
+ useContext,
8
+ useEffect,
9
+ useRef,
10
+ type ComponentProps,
11
+ type MouseEvent,
12
+ type ReactNode,
13
+ } from "react";
14
+ import { useControllableState } from "../../hooks/useControllableState";
15
+ import { usePortalScope } from "../../hooks/usePortalScope";
16
+ import { TriangleRight } from "../../icons/TriangleRight";
17
+ import { cn } from "../../lib/cn";
18
+ import { defined } from "../../lib/defined";
19
+ import { focusRingInset } from "../../lib/focus-ring";
20
+ import {
21
+ menuGroupLabel,
22
+ menuItem,
23
+ menuItemCurrent,
24
+ menuItemHover,
25
+ menuPanel,
26
+ } from "../dropdown-menu/menu-style";
27
+ import { LinkElement, type LinkRender } from "../link-element/LinkElement";
28
+ import { RailIcon, railItemClass, SideRailContext } from "./SideRail";
29
+
30
+ /** 子项要知道自己是排在侧轨里,还是在收起时弹出的那块菜单里 */
31
+ const SubContext = createContext<"list" | "menu">("list");
32
+
33
+ type SubOwnProps = {
34
+ /** 图标。收起时只剩它 */
35
+ icon: ReactNode;
36
+ /** 这一组的名称:父项上的文字,也是子列表和菜单的名称 */
37
+ label: string;
38
+ /** 展开着吗。只在侧轨展开时有意义;收起的侧轨上是弹出的菜单 */
39
+ open?: boolean;
40
+ /**
41
+ * 一开始展开着吗。默认:当前项在里面就展开。
42
+ * 之后当前项换到了里面(从别处导航过来),也会自己展开
43
+ */
44
+ defaultOpen?: boolean;
45
+ onOpenChange?: (open: boolean) => void;
46
+ /** 直接放 `SideRailSubItem` */
47
+ children: ReactNode;
48
+ };
49
+
50
+ export type SideRailSubProps = SubOwnProps &
51
+ Omit<ComponentProps<"li">, keyof SubOwnProps>;
52
+
53
+ /**
54
+ * 侧轨里带二级的栏目。父项本身不是一个去处:点它只是展开收起。
55
+ * 侧轨收起时没有地方就地展开,改成向右弹出一块菜单。
56
+ */
57
+ export function SideRailSub({
58
+ icon,
59
+ label,
60
+ open: openProp,
61
+ defaultOpen,
62
+ onOpenChange,
63
+ children,
64
+ ...props
65
+ }: SideRailSubProps) {
66
+ const { collapsed } = useContext(SideRailContext);
67
+ const { anchorRef, portalRef } = usePortalScope();
68
+
69
+ // 当前项在不在里面:从子元素上读,所以 SideRailSubItem 要直接放在这里
70
+ const containsCurrent = Children.toArray(children).some(
71
+ (child) =>
72
+ isValidElement<{ current?: unknown }>(child) &&
73
+ child.props.current === true,
74
+ );
75
+
76
+ const [open, setOpen] = useControllableState({
77
+ value: openProp,
78
+ defaultValue: defaultOpen ?? containsCurrent,
79
+ onChange: onOpenChange,
80
+ });
81
+
82
+ // 当前项换到了里面(从别处导航过来的):自己展开,否则它藏着,看不出人在哪
83
+ const contained = useRef(containsCurrent);
84
+ useEffect(() => {
85
+ if (containsCurrent && !contained.current) setOpen(true);
86
+ contained.current = containsCurrent;
87
+ }, [containsCurrent, setOpen]);
88
+
89
+ if (collapsed) {
90
+ return (
91
+ <li {...props}>
92
+ {/* 悬停打开的菜单本来就不锁页面;点开的也不锁——它是导航,不是一组操作 */}
93
+ <BaseMenu.Root modal={false}>
94
+ <BaseMenu.Trigger
95
+ ref={anchorRef}
96
+ openOnHover
97
+ delay={0}
98
+ // 子项都藏在菜单里:当前项在里面时,父项替它显示成当前的样子
99
+ className={railItemClass({
100
+ current: containsCurrent,
101
+ disabled: false,
102
+ })}
103
+ >
104
+ <RailIcon>{icon}</RailIcon>
105
+ <span className="sr-only">{label}</span>
106
+ </BaseMenu.Trigger>
107
+ <BaseMenu.Portal ref={portalRef}>
108
+ <BaseMenu.Positioner
109
+ side="right"
110
+ align="start"
111
+ sideOffset={0}
112
+ className="z-(--z-overlay) outline-none"
113
+ >
114
+ <BaseMenu.Popup
115
+ className={cn(
116
+ menuPanel,
117
+ "max-h-(--available-height) overflow-y-auto overscroll-contain",
118
+ )}
119
+ >
120
+ <BaseMenu.Group>
121
+ <BaseMenu.GroupLabel className={menuGroupLabel}>
122
+ {label}
123
+ </BaseMenu.GroupLabel>
124
+ <SubContext value="menu">{children}</SubContext>
125
+ </BaseMenu.Group>
126
+ </BaseMenu.Popup>
127
+ </BaseMenu.Positioner>
128
+ </BaseMenu.Portal>
129
+ </BaseMenu.Root>
130
+ </li>
131
+ );
132
+ }
133
+
134
+ return (
135
+ <li {...props}>
136
+ <BaseCollapsible.Root open={open} onOpenChange={(next) => setOpen(next)}>
137
+ <BaseCollapsible.Trigger
138
+ className={(state) =>
139
+ cn(
140
+ "group/sub",
141
+ // 展开时短竖条在子项上;收着的时候父项替它显示
142
+ railItemClass({
143
+ current: containsCurrent && !state.open,
144
+ disabled: false,
145
+ }),
146
+ // 当前项在里面:展开着父项也不退回次要色
147
+ containsCurrent && "text-ink",
148
+ )
149
+ }
150
+ >
151
+ <RailIcon>{icon}</RailIcon>
152
+ <span className="min-w-0 flex-1 truncate">{label}</span>
153
+ <TriangleRight
154
+ size={8}
155
+ className="shrink-0 transition-transform duration-(--duration-fast) ease-standard group-data-panel-open/sub:rotate-90"
156
+ />
157
+ </BaseCollapsible.Trigger>
158
+ <BaseCollapsible.Panel
159
+ className={cn(
160
+ "h-(--collapsible-panel-height) overflow-hidden",
161
+ "transition-[height] duration-(--duration-base) ease-standard",
162
+ "data-ending-style:h-0 data-starting-style:h-0",
163
+ )}
164
+ >
165
+ {/* 引线对着父项图标的中线(左起 32px) */}
166
+ <ul
167
+ aria-label={label}
168
+ className="relative before:absolute before:inset-y-0 before:left-8 before:w-px before:bg-line before:content-['']"
169
+ >
170
+ <SubContext value="list">{children}</SubContext>
171
+ </ul>
172
+ </BaseCollapsible.Panel>
173
+ </BaseCollapsible.Root>
174
+ </li>
175
+ );
176
+ }
177
+
178
+ type SubItemOwnProps = {
179
+ /** 当前所在的页面:墨色、加粗,引线在这一段变成粗条;输出 `aria-current="page"` */
180
+ current?: boolean;
181
+ /** 行尾的补充:计数、角标 */
182
+ end?: ReactNode;
183
+ disabled?: boolean;
184
+ /** 传了就渲染成链接 */
185
+ href?: string;
186
+ target?: string;
187
+ rel?: string;
188
+ /** 用这个元素代替 `<a>`(路由库的链接组件)。传了就按链接处理 */
189
+ render?: LinkRender;
190
+ /** 没有 `href` 和 `render` 时渲染成按钮 */
191
+ onClick?: (event: MouseEvent<HTMLElement>) => void;
192
+ children: ReactNode;
193
+ };
194
+
195
+ export type SideRailSubItemProps = SubItemOwnProps &
196
+ Omit<ComponentProps<"li">, keyof SubItemOwnProps>;
197
+
198
+ /** 二级里的一项。直接放在 `SideRailSub` 里。 */
199
+ export function SideRailSubItem({
200
+ current = false,
201
+ end,
202
+ disabled = false,
203
+ href,
204
+ target,
205
+ rel,
206
+ render,
207
+ onClick,
208
+ children,
209
+ ...props
210
+ }: SideRailSubItemProps) {
211
+ const placement = useContext(SubContext);
212
+ const isLink = href !== undefined || render !== undefined;
213
+
214
+ const content = (
215
+ <>
216
+ <span className="min-w-0 flex-1 truncate">{children}</span>
217
+ {end && <span className="flex shrink-0 items-center">{end}</span>}
218
+ </>
219
+ );
220
+
221
+ // 收起的侧轨:这一项是弹出菜单里的一个菜单项
222
+ if (placement === "menu") {
223
+ const classes = cn(
224
+ menuItem,
225
+ current ? menuItemCurrent.plain : menuItemHover,
226
+ );
227
+ return isLink ? (
228
+ <BaseMenu.LinkItem
229
+ render={render}
230
+ {...defined({ href: disabled ? undefined : href, target, rel })}
231
+ aria-current={current ? "page" : undefined}
232
+ aria-disabled={disabled || undefined}
233
+ data-disabled={disabled ? "" : undefined}
234
+ closeOnClick
235
+ onClick={disabled ? (event) => event.preventDefault() : onClick}
236
+ className={classes}
237
+ >
238
+ {content}
239
+ </BaseMenu.LinkItem>
240
+ ) : (
241
+ <BaseMenu.Item
242
+ disabled={disabled}
243
+ aria-current={current ? "page" : undefined}
244
+ onClick={onClick}
245
+ className={classes}
246
+ >
247
+ {content}
248
+ </BaseMenu.Item>
249
+ );
250
+ }
251
+
252
+ const classes = cn(
253
+ // 文字和父项的文字对齐:20px 的内边距 + 24px 的图标位 + 12px 的间隔
254
+ "relative flex h-10 w-full items-center gap-3 pr-4 pl-14 text-left text-sm",
255
+ "transition-colors duration-(--duration-fast) ease-standard",
256
+ focusRingInset,
257
+ disabled
258
+ ? "cursor-not-allowed text-ink-disabled"
259
+ : [
260
+ current ? "font-bold text-ink" : "text-ink-secondary",
261
+ "hover:bg-surface-muted hover:text-ink",
262
+ ],
263
+ // 引线在当前项这一段换成粗条
264
+ current &&
265
+ "before:absolute before:inset-y-2 before:left-[31px] before:w-[3px] before:bg-ink before:content-['']",
266
+ );
267
+
268
+ return (
269
+ <li {...props}>
270
+ {isLink ? (
271
+ <LinkElement
272
+ render={render}
273
+ // 禁用的链接去掉 href,保留 link 角色,让读屏仍能读出"不可用"
274
+ href={disabled ? undefined : href}
275
+ role={disabled ? "link" : undefined}
276
+ aria-disabled={disabled || undefined}
277
+ target={target}
278
+ rel={rel}
279
+ aria-current={current ? "page" : undefined}
280
+ onClick={disabled ? (event) => event.preventDefault() : onClick}
281
+ className={classes}
282
+ >
283
+ {content}
284
+ </LinkElement>
285
+ ) : (
286
+ <button
287
+ type="button"
288
+ disabled={disabled}
289
+ aria-current={current ? "page" : undefined}
290
+ onClick={onClick}
291
+ className={classes}
292
+ >
293
+ {content}
294
+ </button>
295
+ )}
296
+ </li>
297
+ );
298
+ }
@@ -0,0 +1,14 @@
1
+ export {
2
+ SideRail,
3
+ SideRailGroup,
4
+ SideRailItem,
5
+ type SideRailGroupProps,
6
+ type SideRailItemProps,
7
+ type SideRailProps,
8
+ } from "./SideRail";
9
+ export {
10
+ SideRailSub,
11
+ SideRailSubItem,
12
+ type SideRailSubItemProps,
13
+ type SideRailSubProps,
14
+ } from "./SideRailSub";
@@ -0,0 +1,70 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export type SkeletonVariant = "block" | "text" | "circle";
5
+
6
+ export type SkeletonProps = Omit<ComponentProps<"span">, "children"> & {
7
+ /**
8
+ * - `block` 与内容等大的色块,尺寸用 `className` 给(`h-40 w-full`),默认;
9
+ * - `text` 几行文字,行高跟随所在位置的字号;
10
+ * - `circle` 圆形,头像、图标。
11
+ */
12
+ variant?: SkeletonVariant;
13
+ /** `text` 的行数,默认 3。多于一行时末行短一截 */
14
+ lines?: number;
15
+ /** 很轻的明度往返。默认不动 */
16
+ pulse?: boolean;
17
+ };
18
+
19
+ const fill = "block bg-surface-muted";
20
+
21
+ /**
22
+ * 骨架:只画大块,不画细节。
23
+ * 自身对辅助技术隐藏;"正在加载"由外层区域的 `aria-busy="true"` 表达。
24
+ */
25
+ export function Skeleton({
26
+ variant = "block",
27
+ lines = 3,
28
+ pulse = false,
29
+ className,
30
+ ...props
31
+ }: SkeletonProps) {
32
+ const motion = pulse && "animate-pulse";
33
+
34
+ if (variant === "text") {
35
+ const count = Math.max(1, Math.floor(lines));
36
+ return (
37
+ <span
38
+ {...props}
39
+ aria-hidden="true"
40
+ data-variant={variant}
41
+ className={cn("flex flex-col gap-[0.6em]", motion, className)}
42
+ >
43
+ {Array.from({ length: count }, (_, index) => (
44
+ <span
45
+ key={index}
46
+ className={cn(
47
+ fill,
48
+ "h-[0.9em]",
49
+ count > 1 && index === count - 1 ? "w-3/5" : "w-full",
50
+ )}
51
+ />
52
+ ))}
53
+ </span>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <span
59
+ {...props}
60
+ aria-hidden="true"
61
+ data-variant={variant}
62
+ className={cn(
63
+ fill,
64
+ variant === "circle" ? "size-10 rounded-full" : "h-16 w-full",
65
+ motion,
66
+ className,
67
+ )}
68
+ />
69
+ );
70
+ }
@@ -0,0 +1 @@
1
+ export { Skeleton, type SkeletonProps, type SkeletonVariant } from "./Skeleton";