@endfield-ui/react 0.0.0-stage → 0.1.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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +27 -0
  3. package/README.md +768 -2
  4. package/dist/index.d.ts +3243 -0
  5. package/dist/index.js +8182 -0
  6. package/dist/styles.css +2 -0
  7. package/package.json +83 -3
  8. package/src/components/README.md +172 -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 +89 -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 +292 -0
  30. package/src/components/carousel/index.ts +7 -0
  31. package/src/components/checkbox/Checkbox.tsx +94 -0
  32. package/src/components/checkbox/choice-style.ts +30 -0
  33. package/src/components/checkbox/index.ts +1 -0
  34. package/src/components/chip/FilterChip.tsx +68 -0
  35. package/src/components/chip/ResourceChip.tsx +63 -0
  36. package/src/components/chip/capsule-style.ts +22 -0
  37. package/src/components/chip/index.ts +6 -0
  38. package/src/components/combobox/Combobox.tsx +450 -0
  39. package/src/components/combobox/index.ts +10 -0
  40. package/src/components/completion-banner/CompletionBanner.tsx +72 -0
  41. package/src/components/completion-banner/index.ts +4 -0
  42. package/src/components/context-menu/ContextMenu.tsx +70 -0
  43. package/src/components/context-menu/index.ts +5 -0
  44. package/src/components/corner-brackets/CornerBrackets.tsx +43 -0
  45. package/src/components/corner-brackets/index.ts +5 -0
  46. package/src/components/countdown/Countdown.tsx +145 -0
  47. package/src/components/countdown/index.ts +6 -0
  48. package/src/components/dash-indicator/DashIndicator.tsx +44 -0
  49. package/src/components/dash-indicator/index.ts +1 -0
  50. package/src/components/data-row/DataRow.tsx +282 -0
  51. package/src/components/data-row/index.ts +8 -0
  52. package/src/components/date-picker/DateField.tsx +159 -0
  53. package/src/components/date-picker/DatePicker.tsx +182 -0
  54. package/src/components/date-picker/DateRangePicker.tsx +203 -0
  55. package/src/components/date-picker/index.ts +7 -0
  56. package/src/components/dialog/Dialog.tsx +203 -0
  57. package/src/components/dialog/index.ts +7 -0
  58. package/src/components/dialog/initial-focus.ts +19 -0
  59. package/src/components/dialog/overlay-style.ts +28 -0
  60. package/src/components/drawer/Drawer.tsx +213 -0
  61. package/src/components/drawer/index.ts +6 -0
  62. package/src/components/dropdown-menu/DropdownMenu.tsx +427 -0
  63. package/src/components/dropdown-menu/MenuCheck.tsx +44 -0
  64. package/src/components/dropdown-menu/index.ts +22 -0
  65. package/src/components/dropdown-menu/menu-style.ts +47 -0
  66. package/src/components/empty-state/EmptyState.tsx +79 -0
  67. package/src/components/empty-state/index.ts +1 -0
  68. package/src/components/field/Field.tsx +203 -0
  69. package/src/components/field/index.ts +6 -0
  70. package/src/components/file-upload/FileItem.tsx +201 -0
  71. package/src/components/file-upload/FileUpload.tsx +425 -0
  72. package/src/components/file-upload/index.ts +9 -0
  73. package/src/components/flyout-bar/FlyoutBar.tsx +158 -0
  74. package/src/components/flyout-bar/index.ts +7 -0
  75. package/src/components/ghost-text/GhostText.tsx +47 -0
  76. package/src/components/ghost-text/index.ts +5 -0
  77. package/src/components/hatch/Hatch.tsx +35 -0
  78. package/src/components/hatch/index.ts +1 -0
  79. package/src/components/hazard-stripe/HazardStripe.tsx +35 -0
  80. package/src/components/hazard-stripe/index.ts +5 -0
  81. package/src/components/icon-button/IconButton.tsx +95 -0
  82. package/src/components/icon-button/index.ts +6 -0
  83. package/src/components/input/Input.tsx +109 -0
  84. package/src/components/input/control-box.ts +71 -0
  85. package/src/components/input/index.ts +6 -0
  86. package/src/components/input/value-chip.ts +34 -0
  87. package/src/components/item-slot/ItemGrid.tsx +207 -0
  88. package/src/components/item-slot/ItemSlot.tsx +283 -0
  89. package/src/components/item-slot/index.ts +7 -0
  90. package/src/components/kbd/Kbd.tsx +33 -0
  91. package/src/components/kbd/index.ts +1 -0
  92. package/src/components/link-element/LinkElement.tsx +25 -0
  93. package/src/components/list/List.tsx +229 -0
  94. package/src/components/list/band-style.ts +18 -0
  95. package/src/components/list/index.ts +8 -0
  96. package/src/components/loader/Loader.tsx +154 -0
  97. package/src/components/loader/index.ts +1 -0
  98. package/src/components/marquee/Marquee.tsx +96 -0
  99. package/src/components/marquee/index.ts +1 -0
  100. package/src/components/media-card/MediaCard.tsx +139 -0
  101. package/src/components/media-card/index.ts +5 -0
  102. package/src/components/nav-action/NavAction.tsx +142 -0
  103. package/src/components/nav-action/index.ts +5 -0
  104. package/src/components/nav-menu/NavMenu.tsx +288 -0
  105. package/src/components/nav-menu/index.ts +6 -0
  106. package/src/components/navigator/Navigator.tsx +136 -0
  107. package/src/components/navigator/index.ts +5 -0
  108. package/src/components/pagination/Pagination.tsx +181 -0
  109. package/src/components/pagination/index.ts +5 -0
  110. package/src/components/panel/Panel.tsx +114 -0
  111. package/src/components/panel/index.ts +13 -0
  112. package/src/components/play-button/PlayButton.tsx +92 -0
  113. package/src/components/play-button/index.ts +7 -0
  114. package/src/components/popover/Popover.tsx +105 -0
  115. package/src/components/popover/index.ts +8 -0
  116. package/src/components/progress/Progress.tsx +133 -0
  117. package/src/components/progress/ProgressRing.tsx +123 -0
  118. package/src/components/progress/index.ts +2 -0
  119. package/src/components/radio/Radio.tsx +182 -0
  120. package/src/components/radio/index.ts +7 -0
  121. package/src/components/rec-indicator/RecIndicator.tsx +47 -0
  122. package/src/components/rec-indicator/index.ts +1 -0
  123. package/src/components/registration-strip/RegistrationStrip.tsx +51 -0
  124. package/src/components/registration-strip/index.ts +5 -0
  125. package/src/components/schedule/Schedule.tsx +457 -0
  126. package/src/components/schedule/index.ts +9 -0
  127. package/src/components/scroll-hint/ScrollHint.tsx +78 -0
  128. package/src/components/scroll-hint/index.ts +5 -0
  129. package/src/components/section-title/SectionTitle.tsx +202 -0
  130. package/src/components/section-title/index.ts +5 -0
  131. package/src/components/segmented-control/SegmentedControl.tsx +220 -0
  132. package/src/components/segmented-control/index.ts +8 -0
  133. package/src/components/select/Select.tsx +390 -0
  134. package/src/components/select/index.ts +15 -0
  135. package/src/components/side-rail/SideRail.tsx +317 -0
  136. package/src/components/side-rail/SideRailSub.tsx +298 -0
  137. package/src/components/side-rail/index.ts +14 -0
  138. package/src/components/skeleton/Skeleton.tsx +70 -0
  139. package/src/components/skeleton/index.ts +1 -0
  140. package/src/components/slider/Slider.tsx +219 -0
  141. package/src/components/slider/index.ts +8 -0
  142. package/src/components/sparkline/Sparkline.tsx +122 -0
  143. package/src/components/sparkline/index.ts +6 -0
  144. package/src/components/spinner/Spinner.tsx +64 -0
  145. package/src/components/spinner/index.ts +1 -0
  146. package/src/components/stat/Stat.tsx +79 -0
  147. package/src/components/stat/index.ts +1 -0
  148. package/src/components/stepper/Stepper.tsx +316 -0
  149. package/src/components/stepper/index.ts +1 -0
  150. package/src/components/steps/Steps.tsx +302 -0
  151. package/src/components/steps/index.ts +10 -0
  152. package/src/components/switch/Switch.tsx +120 -0
  153. package/src/components/switch/index.ts +1 -0
  154. package/src/components/table/Table.tsx +559 -0
  155. package/src/components/table/index.ts +20 -0
  156. package/src/components/tabs/Tabs.tsx +313 -0
  157. package/src/components/tabs/index.ts +12 -0
  158. package/src/components/tag/Tag.tsx +66 -0
  159. package/src/components/tag/TagPair.tsx +34 -0
  160. package/src/components/tag/index.ts +2 -0
  161. package/src/components/tag-input/TagInput.tsx +501 -0
  162. package/src/components/tag-input/index.ts +8 -0
  163. package/src/components/term/Term.tsx +53 -0
  164. package/src/components/term/index.ts +1 -0
  165. package/src/components/textarea/Textarea.tsx +99 -0
  166. package/src/components/textarea/index.ts +1 -0
  167. package/src/components/texture/Texture.tsx +45 -0
  168. package/src/components/texture/index.ts +1 -0
  169. package/src/components/tick-ring/TickRing.tsx +77 -0
  170. package/src/components/tick-ring/index.ts +1 -0
  171. package/src/components/timeline/Timeline.tsx +84 -0
  172. package/src/components/timeline/index.ts +7 -0
  173. package/src/components/toast/Toast.tsx +257 -0
  174. package/src/components/toast/index.ts +10 -0
  175. package/src/components/toc/Toc.tsx +262 -0
  176. package/src/components/toc/index.ts +7 -0
  177. package/src/components/toolbar/Toolbar.tsx +389 -0
  178. package/src/components/toolbar/index.ts +17 -0
  179. package/src/components/tooltip/Tooltip.tsx +175 -0
  180. package/src/components/tooltip/index.ts +8 -0
  181. package/src/components/top-bar/TopBar.tsx +47 -0
  182. package/src/components/top-bar/index.ts +1 -0
  183. package/src/components/viewfinder/Viewfinder.tsx +97 -0
  184. package/src/components/viewfinder/index.ts +6 -0
  185. package/src/hooks/README.md +14 -0
  186. package/src/hooks/index.ts +4 -0
  187. package/src/hooks/useControllableState.ts +30 -0
  188. package/src/hooks/useInView.ts +44 -0
  189. package/src/hooks/useOverflowing.ts +28 -0
  190. package/src/hooks/usePortalScope.ts +68 -0
  191. package/src/hooks/useScrollPosition.ts +57 -0
  192. package/src/hooks/useTheme.ts +102 -0
  193. package/src/icons/ArrowCorner.tsx +7 -0
  194. package/src/icons/ArrowLeft.tsx +6 -0
  195. package/src/icons/ArrowRight.tsx +6 -0
  196. package/src/icons/CalendarIcon.tsx +10 -0
  197. package/src/icons/Check.tsx +4 -0
  198. package/src/icons/ChevronDown.tsx +3 -0
  199. package/src/icons/ChevronLeft.tsx +6 -0
  200. package/src/icons/ChevronRight.tsx +6 -0
  201. package/src/icons/Close.tsx +3 -0
  202. package/src/icons/Crosshair.tsx +7 -0
  203. package/src/icons/Lock.tsx +6 -0
  204. package/src/icons/Menu.tsx +4 -0
  205. package/src/icons/Minus.tsx +4 -0
  206. package/src/icons/Plus.tsx +3 -0
  207. package/src/icons/README.md +31 -0
  208. package/src/icons/StatusDanger.tsx +7 -0
  209. package/src/icons/StatusInfo.tsx +7 -0
  210. package/src/icons/StatusSuccess.tsx +10 -0
  211. package/src/icons/StatusWarning.tsx +7 -0
  212. package/src/icons/TriangleRight.tsx +7 -0
  213. package/src/icons/Upload.tsx +7 -0
  214. package/src/icons/createIcon.tsx +34 -0
  215. package/src/icons/index.ts +21 -0
  216. package/src/index.ts +82 -0
  217. package/src/lib/README.md +17 -0
  218. package/src/lib/cn.ts +34 -0
  219. package/src/lib/date.ts +125 -0
  220. package/src/lib/decor.ts +6 -0
  221. package/src/lib/defined.ts +9 -0
  222. package/src/lib/file.ts +61 -0
  223. package/src/lib/focus-ring.ts +11 -0
  224. package/src/lib/merge-refs.ts +11 -0
  225. package/src/styles/README.md +70 -0
  226. package/src/styles/index.css +25 -0
  227. package/src/styles/theme.css +568 -0
  228. package/src/styles/utilities.css +241 -0
@@ -0,0 +1,124 @@
1
+ import {
2
+ useCallback,
3
+ type ComponentProps,
4
+ type MouseEvent,
5
+ type RefObject,
6
+ } from "react";
7
+ import {
8
+ useScrollPosition,
9
+ type ScrollMetrics,
10
+ } from "../../hooks/useScrollPosition";
11
+ import { ArrowRight } from "../../icons/ArrowRight";
12
+ import { cn } from "../../lib/cn";
13
+ import { focusRing } from "../../lib/focus-ring";
14
+
15
+ export type BackToTopSize = "sm" | "md";
16
+
17
+ export type BackToTopProps = Omit<
18
+ ComponentProps<"button">,
19
+ "children" | "aria-label"
20
+ > & {
21
+ /** 默认"回到顶部" */
22
+ "aria-label"?: string;
23
+ /** 看哪个滚动容器、回到谁的顶部。不传就是整个页面 */
24
+ target?: RefObject<HTMLElement | null>;
25
+ /** 滚过多少像素才出现,默认 400 */
26
+ threshold?: number;
27
+ /** 48px(默认)或 40px 见方 */
28
+ size?: BackToTopSize;
29
+ };
30
+
31
+ const sizeClass: Record<BackToTopSize, string> = {
32
+ sm: "size-10 [&_svg]:size-5",
33
+ md: "size-12 [&_svg]:size-6",
34
+ };
35
+
36
+ /**
37
+ * 把焦点交给一个本来不能聚焦的元素:下一次 Tab 从它里面的头上开始。
38
+ * 它不是控件,所以不画焦点环;焦点一走就把临时加的东西收回来。
39
+ */
40
+ function handFocusTo(node: HTMLElement) {
41
+ if (node.hasAttribute("tabindex")) {
42
+ node.focus({ preventScroll: true });
43
+ return;
44
+ }
45
+ const outline = node.style.outline;
46
+ node.setAttribute("tabindex", "-1");
47
+ node.style.outline = "none";
48
+ node.focus({ preventScroll: true });
49
+ node.addEventListener(
50
+ "blur",
51
+ () => {
52
+ node.removeAttribute("tabindex");
53
+ node.style.outline = outline;
54
+ },
55
+ { once: true },
56
+ );
57
+ }
58
+
59
+ /**
60
+ * 回到顶部:滚过一段才出现的悬浮钮,点了回到最上面,焦点也跟着回去。
61
+ * 默认看整个页面、自己 `fixed` 在视口右下角;放进某个滚动容器时传 `target`,
62
+ * 并用 `className` 把位置改成相对那个容器的。
63
+ */
64
+ export function BackToTop({
65
+ "aria-label": ariaLabel = "回到顶部",
66
+ target,
67
+ threshold = 400,
68
+ size = "md",
69
+ type = "button",
70
+ className,
71
+ onClick,
72
+ ...props
73
+ }: BackToTopProps) {
74
+ const scrolledFar = useCallback(
75
+ ({ scrolled }: ScrollMetrics) => scrolled > threshold,
76
+ [threshold],
77
+ );
78
+ const visible = useScrollPosition(target, scrolledFar, false);
79
+
80
+ const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
81
+ onClick?.(event);
82
+ if (event.defaultPrevented) return;
83
+
84
+ const element = target?.current ?? null;
85
+ // 脚本里显式要的平滑滚动不归 CSS 那条全局规则管,要自己看"减少动态效果"
86
+ const reduce =
87
+ typeof window.matchMedia === "function" &&
88
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
89
+ const options: ScrollToOptions = {
90
+ top: 0,
91
+ behavior: reduce ? "instant" : "smooth",
92
+ };
93
+ if (element) element.scrollTo?.(options);
94
+ else window.scrollTo(options);
95
+
96
+ // 滚到顶之后这个钮会消失:焦点留在它身上就丢了,下一次 Tab 会从页尾开始
97
+ handFocusTo(element ?? document.body);
98
+ };
99
+
100
+ return (
101
+ <button
102
+ {...props}
103
+ type={type}
104
+ aria-label={ariaLabel}
105
+ data-visible={visible ? "" : undefined}
106
+ data-size={size}
107
+ onClick={handleClick}
108
+ className={cn(
109
+ "fixed right-[calc(1rem+env(safe-area-inset-right))] bottom-[calc(1rem+env(safe-area-inset-bottom))] z-(--z-float)",
110
+ "inline-flex shrink-0 items-center justify-center shadow-md",
111
+ // 反转填充:浮在任意内容上,两个主题下都和页面拉得开
112
+ "bg-surface-inverse text-ink-inverse hover:bg-action hover:text-on-action active:bg-action-pressed active:text-on-action",
113
+ "transition-[opacity,visibility,background-color,color] duration-(--duration-fast) ease-standard",
114
+ focusRing,
115
+ sizeClass[size],
116
+ // 没出现时 visibility 是 hidden:Tab 到不了,读屏也读不到
117
+ visible ? "visible opacity-100" : "invisible opacity-0",
118
+ className,
119
+ )}
120
+ >
121
+ <ArrowRight className="-rotate-90" />
122
+ </button>
123
+ );
124
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ BackToTop,
3
+ type BackToTopProps,
4
+ type BackToTopSize,
5
+ } from "./BackToTop";
@@ -0,0 +1,75 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export type BadgeProps = Omit<ComponentProps<"span">, "children"> & {
5
+ /** 数量。显示为橙色数字块 */
6
+ count?: number;
7
+ /** 超过后显示为 `max+`,默认 99 */
8
+ max?: number;
9
+ /** `count` 为 0 时是否仍然显示,默认不显示 */
10
+ showZero?: boolean;
11
+ /** 不带数字的提醒:一个橙色菱形 */
12
+ dot?: boolean;
13
+ /** 给读屏的完整说明,如"12 条未读"。提供后角标本身对辅助技术隐藏 */
14
+ label?: string;
15
+ /** 被标记的元素。角标压在它的右上角,向外偏移半个自身 */
16
+ children?: ReactNode;
17
+ };
18
+
19
+ /** 通知角标:提醒与数量归橙色,不用来表示危险。 */
20
+ export function Badge({
21
+ count,
22
+ max = 99,
23
+ showZero = false,
24
+ dot = false,
25
+ label,
26
+ className,
27
+ children,
28
+ ...props
29
+ }: BadgeProps) {
30
+ let indicator: ReactNode = null;
31
+ if (dot) {
32
+ indicator = (
33
+ <span
34
+ aria-hidden="true"
35
+ className="block size-2 rotate-45 bg-notice"
36
+ data-badge="dot"
37
+ />
38
+ );
39
+ } else if (count !== undefined && (count > 0 || showZero)) {
40
+ indicator = (
41
+ <span
42
+ aria-hidden={label ? "true" : undefined}
43
+ className="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-notice px-1 font-tech text-xs leading-none font-bold text-neutral-900 tabular-nums"
44
+ data-badge="count"
45
+ >
46
+ {count > max ? `${max}+` : count}
47
+ </span>
48
+ );
49
+ }
50
+
51
+ const visible = indicator !== null;
52
+ const description =
53
+ visible && label ? <span className="sr-only">{label}</span> : null;
54
+
55
+ if (children === undefined) {
56
+ return (
57
+ <span {...props} className={cn("inline-flex items-center", className)}>
58
+ {indicator}
59
+ {description}
60
+ </span>
61
+ );
62
+ }
63
+
64
+ return (
65
+ <span {...props} className={cn("relative inline-flex", className)}>
66
+ {children}
67
+ {visible && (
68
+ <span className="pointer-events-none absolute top-0 right-0 translate-x-1/2 -translate-y-1/2">
69
+ {indicator}
70
+ {description}
71
+ </span>
72
+ )}
73
+ </span>
74
+ );
75
+ }
@@ -0,0 +1 @@
1
+ export { Badge, type BadgeProps } from "./Badge";
@@ -0,0 +1,40 @@
1
+ import type { ComponentProps, ElementType, ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export type BracketTitleProps = Omit<ComponentProps<"h3">, "children"> & {
5
+ /** 标题层级,默认 3(`<h3>`) */
6
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
7
+ /** 名称 */
8
+ children: ReactNode;
9
+ };
10
+
11
+ const bracket = "font-medium text-ink-tertiary";
12
+
13
+ /**
14
+ * 方括号标题 `[ 名称 ]`:人物、地点、物品的名称——"一个被归档的条目"。
15
+ * 普通的分节标题用 `SectionTitle`。字号用 `className` 改,默认是名称用的 `text-4xl`。
16
+ */
17
+ export function BracketTitle({
18
+ level = 3,
19
+ className,
20
+ children,
21
+ ...props
22
+ }: BracketTitleProps) {
23
+ const Heading: ElementType = `h${level}`;
24
+
25
+ return (
26
+ // 括号和名称排在同一行内而不是弹性盒里:名称换行时,右括号跟在最后一个字后面
27
+ <Heading
28
+ {...props}
29
+ className={cn("text-4xl font-bold wrap-anywhere text-ink", className)}
30
+ >
31
+ <span aria-hidden="true" className={cn(bracket, "mr-[0.4em]")}>
32
+ [
33
+ </span>
34
+ {children}
35
+ <span aria-hidden="true" className={cn(bracket, "ml-[0.4em]")}>
36
+ ]
37
+ </span>
38
+ </Heading>
39
+ );
40
+ }
@@ -0,0 +1 @@
1
+ export { BracketTitle, type BracketTitleProps } from "./BracketTitle";
@@ -0,0 +1,161 @@
1
+ import {
2
+ Children,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type ComponentProps,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { cn } from "../../lib/cn";
10
+ import { focusRing } from "../../lib/focus-ring";
11
+ import { LinkElement, type LinkRender } from "../link-element/LinkElement";
12
+
13
+ export type BreadcrumbProps = ComponentProps<"nav"> & {
14
+ /**
15
+ * 最多显示几项。超出时保留首项和靠后的几项,中间折叠成 `…`,点击展开。
16
+ * 不传则全部显示。
17
+ */
18
+ maxItems?: number;
19
+ /** 折叠按钮的可访问名称 */
20
+ expandLabel?: string;
21
+ };
22
+
23
+ function Separator() {
24
+ return (
25
+ <span
26
+ aria-hidden="true"
27
+ className="text-ink-tertiary [li:first-child>&]:hidden"
28
+ >
29
+ /
30
+ </span>
31
+ );
32
+ }
33
+
34
+ /** 层级路径:`// 档案 / 干员 / 详情`。双斜杠起头,单斜杠分隔。 */
35
+ export function Breadcrumb({
36
+ maxItems,
37
+ expandLabel = "显示完整路径",
38
+ "aria-label": ariaLabel = "面包屑",
39
+ className,
40
+ children,
41
+ ...props
42
+ }: BreadcrumbProps) {
43
+ const [expanded, setExpanded] = useState(false);
44
+ const listRef = useRef<HTMLOListElement>(null);
45
+ const justExpanded = useRef(false);
46
+
47
+ const items = Children.toArray(children);
48
+ const collapsed =
49
+ maxItems !== undefined &&
50
+ maxItems >= 2 &&
51
+ items.length > maxItems &&
52
+ !expanded;
53
+
54
+ // 展开后折叠按钮消失,把焦点交给第一个刚露出来的链接,键盘用户不会掉回页面开头
55
+ useEffect(() => {
56
+ if (!justExpanded.current) return;
57
+ justExpanded.current = false;
58
+ listRef.current
59
+ ?.querySelector<HTMLElement>("li:nth-child(2) a, li:nth-child(2) button")
60
+ ?.focus();
61
+ }, [expanded]);
62
+
63
+ let visible: ReactNode[] = items;
64
+ if (collapsed) {
65
+ visible = [
66
+ items[0],
67
+ <li key="ellipsis" className="flex items-center gap-2">
68
+ <Separator />
69
+ <button
70
+ type="button"
71
+ aria-label={expandLabel}
72
+ aria-expanded={false}
73
+ onClick={() => {
74
+ justExpanded.current = true;
75
+ setExpanded(true);
76
+ }}
77
+ className={cn(
78
+ "px-1 text-ink-secondary hover:text-ink",
79
+ "transition-colors duration-(--duration-fast) ease-standard",
80
+ focusRing,
81
+ )}
82
+ >
83
+ …
84
+ </button>
85
+ </li>,
86
+ ...items.slice(items.length - (maxItems - 1)),
87
+ ];
88
+ }
89
+
90
+ return (
91
+ <nav
92
+ {...props}
93
+ aria-label={ariaLabel}
94
+ className={cn("flex items-baseline gap-2 text-sm", className)}
95
+ >
96
+ <span aria-hidden="true" className="shrink-0 text-ink-tertiary">
97
+ {"//"}
98
+ </span>
99
+ <ol
100
+ ref={listRef}
101
+ className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1"
102
+ >
103
+ {visible}
104
+ </ol>
105
+ </nav>
106
+ );
107
+ }
108
+
109
+ export type BreadcrumbItemProps = Omit<ComponentProps<"li">, "children"> & {
110
+ /** 传了就渲染成链接 */
111
+ href?: string;
112
+ /** 用这个元素代替 `<a>`(路由库的链接组件)。传了就按链接处理 */
113
+ render?: LinkRender;
114
+ /** 当前页:加粗、不可点击,输出 `aria-current="page"`。给最后一项 */
115
+ current?: boolean;
116
+ children: ReactNode;
117
+ };
118
+
119
+ export function BreadcrumbItem({
120
+ href,
121
+ render,
122
+ current = false,
123
+ className,
124
+ children,
125
+ ...props
126
+ }: BreadcrumbItemProps) {
127
+ let content: ReactNode;
128
+ if (current) {
129
+ content = (
130
+ <span aria-current="page" className="font-bold text-ink">
131
+ {children}
132
+ </span>
133
+ );
134
+ } else if (href !== undefined || render !== undefined) {
135
+ content = (
136
+ <LinkElement
137
+ render={render}
138
+ href={href}
139
+ className={cn(
140
+ "text-ink-secondary underline-offset-4 hover:text-ink hover:underline",
141
+ "transition-colors duration-(--duration-fast) ease-standard",
142
+ focusRing,
143
+ )}
144
+ >
145
+ {children}
146
+ </LinkElement>
147
+ );
148
+ } else {
149
+ content = <span className="text-ink-secondary">{children}</span>;
150
+ }
151
+
152
+ return (
153
+ <li
154
+ {...props}
155
+ className={cn("flex min-w-0 items-baseline gap-2", className)}
156
+ >
157
+ <Separator />
158
+ {content}
159
+ </li>
160
+ );
161
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ Breadcrumb,
3
+ BreadcrumbItem,
4
+ type BreadcrumbItemProps,
5
+ type BreadcrumbProps,
6
+ } from "./Breadcrumb";