@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,168 @@
1
+ import { Accordion as BaseAccordion } from "@base-ui/react/accordion";
2
+ import {
3
+ createContext,
4
+ useContext,
5
+ useMemo,
6
+ type ComponentProps,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { Minus } from "../../icons/Minus";
10
+ import { cn } from "../../lib/cn";
11
+ import { focusRingInset } from "../../lib/focus-ring";
12
+
13
+ export type AccordionSize = "sm" | "md";
14
+ export type AccordionLevel = 2 | 3 | 4 | 5 | 6;
15
+
16
+ const AccordionContext = createContext<{
17
+ size: AccordionSize;
18
+ level: AccordionLevel;
19
+ }>({ size: "md", level: 3 });
20
+
21
+ export type AccordionProps = Omit<
22
+ ComponentProps<"div">,
23
+ "defaultValue" | "onChange" | "style"
24
+ > & {
25
+ /** 可以同时展开几项。默认开了这个,那个就收起来 */
26
+ multiple?: boolean;
27
+ /** 展开着的那几项的 `value` */
28
+ value?: string[];
29
+ defaultValue?: string[];
30
+ onValueChange?: (value: string[]) => void;
31
+ /** 标题行高 40 / 48px,默认 `md` */
32
+ size?: AccordionSize;
33
+ /** 标题的层级,默认 3(`<h3>`)。按它在页面里的位置给 */
34
+ level?: AccordionLevel;
35
+ /**
36
+ * 收起的内容也留在页面里:浏览器的页内查找找得到,找到时自动展开。
37
+ * 常见问题这类"来找一句话"的内容值得开
38
+ */
39
+ searchable?: boolean;
40
+ disabled?: boolean;
41
+ /** `AccordionItem` */
42
+ children: ReactNode;
43
+ };
44
+
45
+ /**
46
+ * 折叠面板:一摞可以各自展开收起的小节。先问能不能不折叠——
47
+ * 藏起来的东西有人会找不到。
48
+ */
49
+ export function Accordion({
50
+ multiple = false,
51
+ value,
52
+ defaultValue,
53
+ onValueChange,
54
+ size = "md",
55
+ level = 3,
56
+ searchable = false,
57
+ disabled,
58
+ className,
59
+ children,
60
+ ...props
61
+ }: AccordionProps) {
62
+ const context = useMemo(() => ({ size, level }), [size, level]);
63
+
64
+ return (
65
+ <AccordionContext value={context}>
66
+ <BaseAccordion.Root
67
+ {...props}
68
+ multiple={multiple}
69
+ value={value}
70
+ defaultValue={defaultValue}
71
+ onValueChange={
72
+ onValueChange && ((next) => onValueChange(next as string[]))
73
+ }
74
+ hiddenUntilFound={searchable}
75
+ disabled={disabled}
76
+ data-size={size}
77
+ className={cn("flex flex-col border-t border-line text-ink", className)}
78
+ >
79
+ {children}
80
+ </BaseAccordion.Root>
81
+ </AccordionContext>
82
+ );
83
+ }
84
+
85
+ const triggerSize: Record<AccordionSize, string> = {
86
+ sm: "min-h-10 py-1.5 text-sm",
87
+ md: "min-h-12 py-2 text-base",
88
+ };
89
+
90
+ type ItemOwnProps = {
91
+ /** 这一项的标识:`Accordion` 的 `value` 里放的就是它 */
92
+ value: string;
93
+ /** 标题:写成"里面是什么" */
94
+ title: ReactNode;
95
+ /** 标题右侧、记号之前的补充:计数、一个标签。它在按钮里,不能放能点的东西 */
96
+ extra?: ReactNode;
97
+ disabled?: boolean;
98
+ /** 内容 */
99
+ children: ReactNode;
100
+ };
101
+
102
+ export type AccordionItemProps = ItemOwnProps &
103
+ Omit<ComponentProps<"div">, keyof ItemOwnProps | "style">;
104
+
105
+ /** 折叠面板里的一节。 */
106
+ export function AccordionItem({
107
+ value,
108
+ title,
109
+ extra,
110
+ disabled,
111
+ className,
112
+ children,
113
+ ...props
114
+ }: AccordionItemProps) {
115
+ const { size, level } = useContext(AccordionContext);
116
+ const Heading = `h${level}` as const;
117
+
118
+ return (
119
+ <BaseAccordion.Item
120
+ {...props}
121
+ value={value}
122
+ disabled={disabled}
123
+ className={cn("border-b border-line", className)}
124
+ >
125
+ <BaseAccordion.Header render={<Heading />} className="flex">
126
+ <BaseAccordion.Trigger
127
+ className={cn(
128
+ "group/trigger flex w-full items-center gap-3 px-4 text-left font-medium",
129
+ "transition-colors duration-(--duration-fast) ease-standard",
130
+ "hover:bg-ink/5 data-panel-open:font-bold",
131
+ "data-disabled:cursor-not-allowed data-disabled:text-ink-disabled data-disabled:hover:bg-transparent",
132
+ focusRingInset,
133
+ triggerSize[size],
134
+ )}
135
+ >
136
+ <span className="min-w-0 flex-1 wrap-anywhere">{title}</span>
137
+ {extra && (
138
+ <span className="flex shrink-0 items-center gap-2 text-sm font-normal text-ink-secondary group-data-disabled/trigger:text-ink-disabled">
139
+ {extra}
140
+ </span>
141
+ )}
142
+ {/* 加号:两笔叠着,竖的那一笔在展开时转平,和横的重合成减号 */}
143
+ <span
144
+ aria-hidden="true"
145
+ className="relative flex size-4 shrink-0 items-center justify-center text-ink-secondary group-data-disabled/trigger:text-ink-disabled"
146
+ >
147
+ <Minus size={16} />
148
+ <Minus
149
+ size={16}
150
+ className="absolute rotate-90 transition-transform duration-(--duration-fast) ease-standard group-data-panel-open/trigger:rotate-0"
151
+ />
152
+ </span>
153
+ </BaseAccordion.Trigger>
154
+ </BaseAccordion.Header>
155
+ <BaseAccordion.Panel
156
+ className={cn(
157
+ "h-(--accordion-panel-height) overflow-hidden",
158
+ "transition-[height] duration-(--duration-base) ease-standard",
159
+ "data-ending-style:h-0 data-starting-style:h-0",
160
+ )}
161
+ >
162
+ <div className={cn("px-4 pb-4", size === "sm" && "text-sm")}>
163
+ {children}
164
+ </div>
165
+ </BaseAccordion.Panel>
166
+ </BaseAccordion.Item>
167
+ );
168
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ Accordion,
3
+ AccordionItem,
4
+ type AccordionItemProps,
5
+ type AccordionLevel,
6
+ type AccordionProps,
7
+ type AccordionSize,
8
+ } from "./Accordion";
@@ -0,0 +1,106 @@
1
+ import type { ComponentProps, ComponentType, ReactNode } from "react";
2
+ import { Close } from "../../icons/Close";
3
+ import type { IconProps } from "../../icons/createIcon";
4
+ import { StatusDanger } from "../../icons/StatusDanger";
5
+ import { StatusInfo } from "../../icons/StatusInfo";
6
+ import { StatusSuccess } from "../../icons/StatusSuccess";
7
+ import { StatusWarning } from "../../icons/StatusWarning";
8
+ import { cn } from "../../lib/cn";
9
+ import { focusRing } from "../../lib/focus-ring";
10
+
11
+ export type AlertTone = "info" | "success" | "warning" | "danger";
12
+
13
+ export type AlertProps = Omit<ComponentProps<"div">, "title"> & {
14
+ /** 色调只点在左缘色条和图标上,底色保持中性。默认 `info` */
15
+ tone?: AlertTone;
16
+ /** 加粗的一行标题,可省 */
17
+ title?: ReactNode;
18
+ /** 右侧的一个操作,通常是 `text` 按钮 */
19
+ action?: ReactNode;
20
+ /** 传了就出现关闭图标。提示条自己不隐藏,由使用方决定 */
21
+ onClose?: () => void;
22
+ /** 关闭图标的可访问名称 */
23
+ closeLabel?: string;
24
+ /** 替换默认图标;传 `null` 去掉 */
25
+ icon?: ReactNode;
26
+ };
27
+
28
+ /* 四种色调用四种不同外形的图标:方框、圆、三角、菱形 */
29
+ const toneIcon: Record<AlertTone, ComponentType<IconProps>> = {
30
+ info: StatusInfo,
31
+ success: StatusSuccess,
32
+ warning: StatusWarning,
33
+ danger: StatusDanger,
34
+ };
35
+
36
+ const toneBar: Record<AlertTone, string> = {
37
+ info: "border-l-info",
38
+ success: "border-l-success",
39
+ warning: "border-l-warning",
40
+ danger: "border-l-danger",
41
+ };
42
+
43
+ const toneText: Record<AlertTone, string> = {
44
+ info: "text-info",
45
+ success: "text-success",
46
+ warning: "text-warning",
47
+ danger: "text-danger",
48
+ };
49
+
50
+ /** 页面内的一条常驻消息。需要用户处理的重要信息用它,不用会消失的轻提示。 */
51
+ export function Alert({
52
+ tone = "info",
53
+ title,
54
+ action,
55
+ onClose,
56
+ closeLabel = "关闭",
57
+ icon,
58
+ className,
59
+ children,
60
+ ...props
61
+ }: AlertProps) {
62
+ const Icon = toneIcon[tone];
63
+ const graphic = icon === undefined ? <Icon size={20} /> : icon;
64
+
65
+ return (
66
+ <div
67
+ role={tone === "danger" || tone === "warning" ? "alert" : "status"}
68
+ {...props}
69
+ data-tone={tone}
70
+ className={cn(
71
+ "flex items-start gap-3 border border-l-4 border-line bg-surface-raised py-3 pr-3 pl-3 text-sm text-ink",
72
+ toneBar[tone],
73
+ className,
74
+ )}
75
+ >
76
+ {graphic !== null && (
77
+ <span aria-hidden="true" className={cn("shrink-0", toneText[tone])}>
78
+ {graphic}
79
+ </span>
80
+ )}
81
+ <div className="min-w-0 flex-1 wrap-anywhere">
82
+ {title && <p className="font-medium">{title}</p>}
83
+ {children && (
84
+ <div className={cn(title && "mt-0.5 text-ink-secondary")}>
85
+ {children}
86
+ </div>
87
+ )}
88
+ </div>
89
+ {action && <div className="shrink-0">{action}</div>}
90
+ {onClose && (
91
+ <button
92
+ type="button"
93
+ aria-label={closeLabel}
94
+ onClick={onClose}
95
+ className={cn(
96
+ "-my-1.5 -mr-1 inline-flex size-8 shrink-0 items-center justify-center text-ink-secondary hover:bg-ink/5 hover:text-ink active:bg-ink/10",
97
+ "transition-colors duration-(--duration-fast) ease-standard",
98
+ focusRing,
99
+ )}
100
+ >
101
+ <Close size={16} />
102
+ </button>
103
+ )}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1 @@
1
+ export { Alert, type AlertProps, type AlertTone } from "./Alert";
@@ -0,0 +1,92 @@
1
+ import { Avatar as BaseAvatar } from "@base-ui/react/avatar";
2
+ import type { ComponentProps, ReactNode } from "react";
3
+ import { cn } from "../../lib/cn";
4
+
5
+ export type AvatarSize = "sm" | "md" | "lg" | "xl";
6
+
7
+ export type AvatarProps = Omit<ComponentProps<"span">, "style"> & {
8
+ /** 名字。没有图时显示它的首字;也是头像默认的可访问名称 */
9
+ name?: string;
10
+ /** 图片地址。没给、没加载出来、加载失败时显示首字 */
11
+ src?: string;
12
+ /**
13
+ * 可访问名称,默认取 `name`。
14
+ * 旁边已经写了名字时传 `""`:头像变成纯装饰,对读屏隐藏
15
+ */
16
+ alt?: string;
17
+ /** 32 / 40 / 56 / 80px,默认 `md` */
18
+ size?: AvatarSize;
19
+ /** 选中:外面一圈行动色的环 */
20
+ selected?: boolean;
21
+ /** 没有图时显示的东西,代替首字(比如一个图标) */
22
+ children?: ReactNode;
23
+ };
24
+
25
+ const sizeClass: Record<AvatarSize, string> = {
26
+ sm: "size-8 text-xs",
27
+ md: "size-10 text-sm",
28
+ lg: "size-14 text-lg",
29
+ xl: "size-20 text-2xl",
30
+ };
31
+
32
+ /*
33
+ * 选中环:2px 的间隔 + 3px 的行动色 + 1px 的墨线。
34
+ * 墨线是给亮色页面的——黄色在白底上看不出来
35
+ */
36
+ export const avatarSelectedRing =
37
+ "shadow-[0_0_0_2px_var(--ef-surface),0_0_0_5px_var(--ef-action),0_0_0_6px_var(--ef-ink)]";
38
+
39
+ /** 中文名取第一个字;拉丁字母的名字取前两个词的首字母 */
40
+ export function initialsOf(name: string): string {
41
+ const words = name.trim().split(/\s+/).filter(Boolean);
42
+ // 展开是按码位拆的,取到的是完整的一个字;用下标取,生僻字会只拿到半个
43
+ // oxlint-disable-next-line typescript/no-misused-spread
44
+ const first = [...(words[0] ?? "")][0];
45
+ if (!first) return "";
46
+ if (!/[a-z]/i.test(first)) return first;
47
+ // oxlint-disable-next-line typescript/no-misused-spread -- 同上
48
+ const second = words.length > 1 ? ([...words[1]!][0] ?? "") : "";
49
+ return (first + second).toUpperCase();
50
+ }
51
+
52
+ /** 头像:代表一个人或一个角色的圆形图。自己不能点。 */
53
+ export function Avatar({
54
+ name,
55
+ src,
56
+ alt = name ?? "",
57
+ size = "md",
58
+ selected = false,
59
+ className,
60
+ children,
61
+ ...props
62
+ }: AvatarProps) {
63
+ const decorative = alt === "";
64
+
65
+ return (
66
+ <BaseAvatar.Root
67
+ {...props}
68
+ // 首字和图都只是这个名字的两种画法:整体是一张以名字命名的图
69
+ {...(decorative
70
+ ? { "aria-hidden": true }
71
+ : { role: "img", "aria-label": alt })}
72
+ data-size={size}
73
+ data-selected={selected ? "" : undefined}
74
+ className={cn(
75
+ "relative inline-flex shrink-0 items-center justify-center overflow-clip rounded-full bg-surface-muted font-medium text-ink-secondary select-none",
76
+ sizeClass[size],
77
+ selected && avatarSelectedRing,
78
+ className,
79
+ )}
80
+ >
81
+ {src && (
82
+ <BaseAvatar.Image src={src} alt="" className="size-full object-cover" />
83
+ )}
84
+ <BaseAvatar.Fallback
85
+ aria-hidden="true"
86
+ className="flex size-full items-center justify-center leading-none [&_svg]:size-1/2"
87
+ >
88
+ {children ?? (name ? initialsOf(name) : null)}
89
+ </BaseAvatar.Fallback>
90
+ </BaseAvatar.Root>
91
+ );
92
+ }
@@ -0,0 +1,261 @@
1
+ import {
2
+ Children,
3
+ createContext,
4
+ isValidElement,
5
+ useContext,
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useRef,
10
+ type ComponentProps,
11
+ type ReactNode,
12
+ } from "react";
13
+ import { useControllableState } from "../../hooks/useControllableState";
14
+ import { ChevronDown } from "../../icons/ChevronDown";
15
+ import { ChevronLeft } from "../../icons/ChevronLeft";
16
+ import { ChevronRight } from "../../icons/ChevronRight";
17
+ import { cn } from "../../lib/cn";
18
+ import { IconButton } from "../icon-button/IconButton";
19
+ import { Avatar, type AvatarSize } from "./Avatar";
20
+
21
+ export type AvatarSwitcherOrientation = "vertical" | "horizontal";
22
+
23
+ type SwitcherContextValue = {
24
+ name: string;
25
+ value: string | null;
26
+ size: AvatarSize;
27
+ select: (value: string) => void;
28
+ };
29
+
30
+ const SwitcherContext = createContext<SwitcherContextValue | null>(null);
31
+
32
+ export type AvatarSwitcherProps = Omit<
33
+ ComponentProps<"div">,
34
+ "aria-label" | "defaultValue" | "onChange"
35
+ > & {
36
+ /** 选的是什么,比如"人员" */
37
+ "aria-label": string;
38
+ /** 选中的那一个的 `value` */
39
+ value?: string;
40
+ defaultValue?: string;
41
+ onValueChange?: (value: string) => void;
42
+ /** 竖排(默认)或横排 */
43
+ orientation?: AvatarSwitcherOrientation;
44
+ /** 头像的大小,默认 `lg`(56px) */
45
+ size?: AvatarSize;
46
+ /** 翻页钮到头之后绕回另一头。默认到头时禁用 */
47
+ loop?: boolean;
48
+ /** 表单字段名:选中的值会随表单提交 */
49
+ name?: string;
50
+ previousLabel?: string;
51
+ nextLabel?: string;
52
+ /** 直接放 `AvatarSwitcherItem` */
53
+ children: ReactNode;
54
+ };
55
+
56
+ type ItemLike = { value?: unknown; disabled?: unknown };
57
+
58
+ /**
59
+ * 头像切换:一列圆形头像里选一个,两端各一个圆形翻页钮。
60
+ * 语义是单选组——方向键换人。头像放不下时给它一个高度(横排时是宽度)上限,
61
+ * 头像在两个翻页钮之间滚动。
62
+ */
63
+ export function AvatarSwitcher({
64
+ value: valueProp,
65
+ defaultValue,
66
+ onValueChange,
67
+ orientation = "vertical",
68
+ size = "lg",
69
+ loop = false,
70
+ name: nameProp,
71
+ previousLabel = "上一个",
72
+ nextLabel = "下一个",
73
+ className,
74
+ children,
75
+ ...props
76
+ }: AvatarSwitcherProps) {
77
+ const generatedName = useId();
78
+ const name = nameProp ?? generatedName;
79
+ const vertical = orientation === "vertical";
80
+ const scrollerRef = useRef<HTMLDivElement>(null);
81
+
82
+ const [value, setValue] = useControllableState<string | null>({
83
+ value: valueProp,
84
+ defaultValue: defaultValue ?? null,
85
+ onChange: (next) => {
86
+ if (next !== null) onValueChange?.(next);
87
+ },
88
+ });
89
+
90
+ // 翻页钮要知道前后是谁:从子元素上读,所以子元素必须直接是 AvatarSwitcherItem
91
+ const values = Children.toArray(children).flatMap((child) => {
92
+ if (!isValidElement<ItemLike>(child)) return [];
93
+ const { value: itemValue, disabled } = child.props;
94
+ return typeof itemValue === "string" && !disabled ? [itemValue] : [];
95
+ });
96
+ const index = value === null ? -1 : values.indexOf(value);
97
+ const last = values.length - 1;
98
+ const step = (delta: 1 | -1) => {
99
+ if (values.length === 0) return;
100
+ const next =
101
+ index === -1
102
+ ? delta === 1
103
+ ? 0
104
+ : last
105
+ : loop
106
+ ? (index + delta + values.length) % values.length
107
+ : Math.min(last, Math.max(0, index + delta));
108
+ setValue(values[next]!);
109
+ };
110
+
111
+ // 选中的那个滚进来。只动自己的滚动容器,不带着页面一起滚
112
+ useEffect(() => {
113
+ const scroller = scrollerRef.current;
114
+ const item = scroller?.querySelector<HTMLElement>("[data-current]");
115
+ if (!scroller || !item) return;
116
+ const start = vertical ? item.offsetTop : item.offsetLeft;
117
+ const length = vertical ? item.offsetHeight : item.offsetWidth;
118
+ const view = vertical ? scroller.clientHeight : scroller.clientWidth;
119
+ const scrolled = vertical ? scroller.scrollTop : scroller.scrollLeft;
120
+ // 留出选中环和焦点环的位置
121
+ const margin = 12;
122
+ let target = scrolled;
123
+ if (start - margin < scrolled) target = start - margin;
124
+ else if (start + length + margin > scrolled + view) {
125
+ target = start + length + margin - view;
126
+ }
127
+ if (target !== scrolled) {
128
+ scroller.scrollTo?.(vertical ? { top: target } : { left: target });
129
+ }
130
+ }, [value, vertical]);
131
+
132
+ const context = useMemo(
133
+ () => ({ name, value, size, select: setValue }),
134
+ [name, value, size, setValue],
135
+ );
136
+
137
+ const Previous = vertical ? ChevronDown : ChevronLeft;
138
+ const Next = vertical ? ChevronDown : ChevronRight;
139
+
140
+ return (
141
+ <SwitcherContext value={context}>
142
+ <div
143
+ {...props}
144
+ role="radiogroup"
145
+ aria-orientation={orientation}
146
+ data-orientation={orientation}
147
+ className={cn(
148
+ // 比容器宽的时候缩到容器的宽度:头像在两个翻页钮之间滚动,不撑破页面
149
+ "inline-flex max-w-full items-center gap-1",
150
+ vertical ? "flex-col" : "flex-row",
151
+ className,
152
+ )}
153
+ >
154
+ {/* 翻页钮不占 Tab 顺序:键盘在单选组里用方向键就够了 */}
155
+ <IconButton
156
+ variant="floating"
157
+ size="sm"
158
+ tabIndex={-1}
159
+ aria-label={previousLabel}
160
+ disabled={!loop && index <= 0}
161
+ onClick={() => step(-1)}
162
+ >
163
+ <Previous className={vertical ? "rotate-180" : undefined} />
164
+ </IconButton>
165
+ <div
166
+ ref={scrollerRef}
167
+ className={cn(
168
+ // 内边距给选中环和焦点环留位置,否则会被滚动容器裁掉
169
+ "relative flex min-h-0 min-w-0 flex-1 gap-4 p-3 [scrollbar-width:none]",
170
+ vertical
171
+ ? "flex-col overflow-x-hidden overflow-y-auto"
172
+ : "flex-row overflow-x-auto overflow-y-hidden",
173
+ )}
174
+ >
175
+ {children}
176
+ </div>
177
+ <IconButton
178
+ variant="floating"
179
+ size="sm"
180
+ tabIndex={-1}
181
+ aria-label={nextLabel}
182
+ disabled={!loop && index >= last}
183
+ onClick={() => step(1)}
184
+ >
185
+ <Next />
186
+ </IconButton>
187
+ </div>
188
+ </SwitcherContext>
189
+ );
190
+ }
191
+
192
+ type ItemOwnProps = {
193
+ value: string;
194
+ /** 名字:这一项的可访问名称,没有图时显示它的首字 */
195
+ label: string;
196
+ /** 头像的图片地址 */
197
+ src?: string;
198
+ disabled?: boolean;
199
+ /** 没有图时显示的东西,代替首字 */
200
+ children?: ReactNode;
201
+ };
202
+
203
+ export type AvatarSwitcherItemProps = ItemOwnProps &
204
+ Omit<ComponentProps<"label">, keyof ItemOwnProps>;
205
+
206
+ /** 头像切换里的一个头像。 */
207
+ export function AvatarSwitcherItem({
208
+ value,
209
+ label,
210
+ src,
211
+ disabled = false,
212
+ className,
213
+ children,
214
+ ...props
215
+ }: AvatarSwitcherItemProps) {
216
+ const context = useContext(SwitcherContext);
217
+ if (!context) {
218
+ throw new Error("AvatarSwitcherItem 要放在 AvatarSwitcher 里面");
219
+ }
220
+ const checked = context.value === value;
221
+
222
+ return (
223
+ <label
224
+ {...props}
225
+ data-current={checked ? "" : undefined}
226
+ className={cn(
227
+ "relative flex shrink-0 rounded-full",
228
+ disabled ? "cursor-not-allowed" : "cursor-pointer",
229
+ className,
230
+ )}
231
+ >
232
+ <input
233
+ type="radio"
234
+ name={context.name}
235
+ value={value}
236
+ checked={checked}
237
+ disabled={disabled}
238
+ aria-label={label}
239
+ onChange={() => context.select(value)}
240
+ className="peer absolute inset-0 size-full cursor-[inherit] appearance-none rounded-full outline-none"
241
+ />
242
+ <Avatar
243
+ name={label}
244
+ src={src}
245
+ alt=""
246
+ size={context.size}
247
+ selected={checked}
248
+ className={cn(
249
+ "pointer-events-none transition-opacity duration-(--duration-fast) ease-standard",
250
+ // 选中不只靠那一圈黄:其余的退后一档
251
+ !checked && "opacity-60 peer-hover:opacity-100",
252
+ disabled && "opacity-30 peer-hover:opacity-30",
253
+ // 焦点环画在选中环(6px)的外面,隔 1px
254
+ "peer-focus-visible:outline-2 peer-focus-visible:outline-offset-[7px] peer-focus-visible:outline-focus",
255
+ )}
256
+ >
257
+ {children}
258
+ </Avatar>
259
+ </label>
260
+ );
261
+ }
@@ -0,0 +1,8 @@
1
+ export { Avatar, type AvatarProps, type AvatarSize } from "./Avatar";
2
+ export {
3
+ AvatarSwitcher,
4
+ AvatarSwitcherItem,
5
+ type AvatarSwitcherItemProps,
6
+ type AvatarSwitcherOrientation,
7
+ type AvatarSwitcherProps,
8
+ } from "./AvatarSwitcher";