@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,202 @@
1
+ import type { ComponentProps, ElementType, ReactNode } from "react";
2
+ import { useInView } from "../../hooks/useInView";
3
+ import { ArrowCorner } from "../../icons/ArrowCorner";
4
+ import { cn } from "../../lib/cn";
5
+
6
+ export type SectionTitleVariant = "standard" | "plain" | "band" | "side";
7
+
8
+ export type SectionTitleProps = Omit<ComponentProps<"header">, "title"> & {
9
+ /**
10
+ * - `standard` 灰色滑块 + 斜箭头 + 英文,下接中文。版块的第一级标题,默认;
11
+ * - `plain` 只留标题和左侧一条短竖条。文档、设置这类工具页面;
12
+ * - `band` 通栏信号黄色带。亮暗版块之间的过渡,一页最多一到两条;
13
+ * - `side` 竖排侧签。贴在横向展开的版块左缘,窄屏下回到横排。
14
+ */
15
+ variant?: SectionTitleVariant;
16
+ /** 真正的标题文字 */
17
+ children: ReactNode;
18
+ /** 英文结构层:`standard` 的栏目名、`side` 的大字。写真实的栏目名 */
19
+ latin?: ReactNode;
20
+ /** `band` 主题上方的小号副题 */
21
+ subtitle?: ReactNode;
22
+ /** `standard` 顶部的微文字行,如 `// 新闻 2026.10.08` */
23
+ meta?: ReactNode;
24
+ /** 标题层级,默认 2(`<h2>`) */
25
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
26
+ /** `standard` 进入视口时播放一次入场,默认开启 */
27
+ animate?: boolean;
28
+ /** 英文只是标题的翻译时对读屏隐藏,避免重复朗读。默认隐藏 */
29
+ latinHidden?: boolean;
30
+ /** `side` 的底色:`accent` 信号黄,`muted` 浅灰 */
31
+ tone?: "accent" | "muted";
32
+ /** `band` 与 `side` 的线稿插图位,纯装饰 */
33
+ illustration?: ReactNode;
34
+ };
35
+
36
+ export function SectionTitle({
37
+ variant = "standard",
38
+ children,
39
+ latin,
40
+ subtitle,
41
+ meta,
42
+ level = 2,
43
+ animate = true,
44
+ latinHidden = true,
45
+ tone = "accent",
46
+ illustration,
47
+ className,
48
+ ref: forwardedRef,
49
+ ...props
50
+ }: SectionTitleProps) {
51
+ const Heading: ElementType = `h${level}`;
52
+ const reveal = variant === "standard" && animate;
53
+ const [inViewRef, inView] = useInView<HTMLElement>({ disabled: !reveal });
54
+
55
+ const ref = (node: HTMLElement | null) => {
56
+ inViewRef.current = node;
57
+ if (typeof forwardedRef === "function") forwardedRef(node);
58
+ else if (forwardedRef) forwardedRef.current = node;
59
+ };
60
+
61
+ if (variant === "plain") {
62
+ return (
63
+ <header
64
+ {...props}
65
+ ref={ref}
66
+ className={cn("flex items-center gap-3", className)}
67
+ >
68
+ <span aria-hidden="true" className="h-6 w-1 shrink-0 bg-ink" />
69
+ <Heading className="min-w-0 text-2xl font-bold wrap-anywhere text-ink">
70
+ {children}
71
+ </Heading>
72
+ </header>
73
+ );
74
+ }
75
+
76
+ if (variant === "band") {
77
+ // 色带按自身宽度响应(容器查询),不看视口:它可能被放进窄栏里。
78
+ // 字号与留白随宽度分三档,插图在窄处去掉;wrap-anywhere 兜底,长单词也不会溢出
79
+ return (
80
+ <header
81
+ {...props}
82
+ ref={ref}
83
+ className={cn("@container bg-action text-on-action", className)}
84
+ >
85
+ <div className="flex min-h-16 items-center gap-4 px-4 py-4 @md:min-h-24 @md:gap-6 @md:px-10">
86
+ {illustration && (
87
+ <div aria-hidden="true" className="hidden shrink-0 @sm:block">
88
+ {illustration}
89
+ </div>
90
+ )}
91
+ <div className="min-w-0">
92
+ {subtitle && (
93
+ <p className="font-latin text-base leading-tight font-light wrap-anywhere uppercase @md:text-lg">
94
+ {subtitle}
95
+ </p>
96
+ )}
97
+ <Heading className="font-latin text-xl font-medium wrap-anywhere uppercase @xs:text-2xl @md:text-3xl">
98
+ {children}
99
+ </Heading>
100
+ </div>
101
+ </div>
102
+ </header>
103
+ );
104
+ }
105
+
106
+ if (variant === "side") {
107
+ return (
108
+ <header
109
+ {...props}
110
+ ref={ref}
111
+ className={cn(
112
+ "flex flex-wrap items-center gap-3 px-4 py-3",
113
+ "md:self-stretch md:px-3 md:py-6 md:[writing-mode:vertical-rl]",
114
+ tone === "accent"
115
+ ? "bg-action text-on-action"
116
+ : "bg-surface-muted text-ink",
117
+ className,
118
+ )}
119
+ >
120
+ {illustration && (
121
+ <div aria-hidden="true" className="shrink-0">
122
+ {illustration}
123
+ </div>
124
+ )}
125
+ <Heading className="text-sm font-medium">{children}</Heading>
126
+ {latin && (
127
+ <p
128
+ aria-hidden={latinHidden || undefined}
129
+ className="max-w-full font-latin text-2xl font-medium wrap-anywhere uppercase"
130
+ >
131
+ {latin}
132
+ </p>
133
+ )}
134
+ </header>
135
+ );
136
+ }
137
+
138
+ // 入场顺序:滑块 → 箭头 → 文字,每步错开 100ms
139
+ const pending = reveal && !inView;
140
+ const playing = reveal && inView;
141
+
142
+ return (
143
+ <header {...props} ref={ref} className={className}>
144
+ {meta && (
145
+ <p
146
+ className={cn(
147
+ "mb-2 font-tech text-xs text-ink-secondary",
148
+ pending && "opacity-0",
149
+ playing && "animate-fade-in [animation-delay:200ms]",
150
+ )}
151
+ >
152
+ {meta}
153
+ </p>
154
+ )}
155
+ {/* 放不下时英文折到滑块下面一行,而不是顶出容器 */}
156
+ <div className="flex flex-wrap items-center gap-x-2">
157
+ <span
158
+ aria-hidden="true"
159
+ className="relative block h-8 w-26 shrink-0 overflow-hidden"
160
+ >
161
+ {/* 滑块在亮暗两个主题下都是浅灰,所以箭头固定用墨色 */}
162
+ <span
163
+ className={cn(
164
+ "absolute inset-0 flex items-center justify-end bg-neutral-300 pr-1.5 text-neutral-900",
165
+ pending && "-translate-x-full",
166
+ playing && "animate-slide-in",
167
+ )}
168
+ >
169
+ <ArrowCorner
170
+ size={20}
171
+ className={cn(
172
+ pending && "-rotate-45",
173
+ playing && "animate-turn-in [animation-delay:100ms]",
174
+ )}
175
+ />
176
+ </span>
177
+ </span>
178
+ {latin && (
179
+ <p
180
+ aria-hidden={latinHidden || undefined}
181
+ className={cn(
182
+ "max-w-full font-latin text-xl leading-8 font-medium wrap-anywhere text-ink uppercase",
183
+ pending && "opacity-0",
184
+ playing && "animate-fade-in [animation-delay:200ms]",
185
+ )}
186
+ >
187
+ {latin}
188
+ </p>
189
+ )}
190
+ </div>
191
+ <Heading
192
+ className={cn(
193
+ "mt-1 text-2xl font-bold wrap-anywhere text-ink",
194
+ pending && "opacity-0",
195
+ playing && "animate-fade-in [animation-delay:200ms]",
196
+ )}
197
+ >
198
+ {children}
199
+ </Heading>
200
+ </header>
201
+ );
202
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ SectionTitle,
3
+ type SectionTitleProps,
4
+ type SectionTitleVariant,
5
+ } from "./SectionTitle";
@@ -0,0 +1,220 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useId,
5
+ useMemo,
6
+ type ComponentProps,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { useControllableState } from "../../hooks/useControllableState";
10
+ import { cn } from "../../lib/cn";
11
+ import { useFieldContext } from "../field/Field";
12
+ import {
13
+ controlSize,
14
+ type ControlSize,
15
+ type ControlVariant,
16
+ } from "../input/control-box";
17
+
18
+ export type SegmentedControlSize = ControlSize;
19
+ export type SegmentedControlVariant = ControlVariant;
20
+
21
+ type SegmentedContextValue = {
22
+ name: string;
23
+ value: string | undefined;
24
+ select: (value: string) => void;
25
+ size: SegmentedControlSize;
26
+ disabled: boolean;
27
+ required: boolean;
28
+ invalid: boolean;
29
+ };
30
+
31
+ const SegmentedContext = createContext<SegmentedContextValue | null>(null);
32
+
33
+ export type SegmentedControlProps = Omit<
34
+ ComponentProps<"div">,
35
+ "onChange" | "defaultValue"
36
+ > & {
37
+ /** 受控的当前值 */
38
+ value?: string;
39
+ /** 非受控时的初始值 */
40
+ defaultValue?: string;
41
+ onValueChange?: (value: string) => void;
42
+ /** 表单字段名:选中的值会随表单提交。缺省自动生成 */
43
+ name?: string;
44
+ /** 32 / 40 / 56px 高,和输入框同三档 */
45
+ size?: SegmentedControlSize;
46
+ /**
47
+ * 轨道的画法,和输入框相同:
48
+ * - `sunken` 凹陷的底 + 一条底边线,默认;
49
+ * - `outline` 四边描边,放在凹陷底色的区域里时用。
50
+ */
51
+ variant?: SegmentedControlVariant;
52
+ disabled?: boolean;
53
+ required?: boolean;
54
+ /** 错误态。放在 `Field` 里时跟随它的 `error` */
55
+ invalid?: boolean;
56
+ };
57
+
58
+ const track: Record<SegmentedControlVariant, string> = {
59
+ sunken: "border-b-2 bg-surface-sunken",
60
+ outline: "border bg-surface",
61
+ };
62
+
63
+ /**
64
+ * 分段选择:两到五个值里选一个,选项并排摆在一条轨道里。
65
+ * 语义是单选组——方向键换值,带 `name` 时随表单提交。里面只放 `Segment`。
66
+ * 标签用 `Field` 包住它,或者自己传 `aria-label`。
67
+ *
68
+ * 切换的是下面显示哪一块内容时用 `Tabs`,不是它。
69
+ */
70
+ export function SegmentedControl({
71
+ value,
72
+ defaultValue,
73
+ onValueChange,
74
+ name,
75
+ size = "md",
76
+ variant = "sunken",
77
+ disabled: disabledProp,
78
+ required: requiredProp,
79
+ invalid: invalidProp,
80
+ className,
81
+ ...props
82
+ }: SegmentedControlProps) {
83
+ const field = useFieldContext();
84
+ const autoName = useId();
85
+ const [current, setCurrent] = useControllableState<string | undefined>({
86
+ value,
87
+ defaultValue,
88
+ onChange: (next) => {
89
+ if (next !== undefined) onValueChange?.(next);
90
+ },
91
+ });
92
+
93
+ const disabled = disabledProp ?? field?.disabled ?? false;
94
+ const required = requiredProp ?? field?.required ?? false;
95
+ const invalid = invalidProp ?? field?.invalid ?? false;
96
+ const groupName = name ?? autoName;
97
+
98
+ const context = useMemo<SegmentedContextValue>(
99
+ () => ({
100
+ name: groupName,
101
+ value: current,
102
+ select: setCurrent,
103
+ size,
104
+ disabled,
105
+ required,
106
+ invalid,
107
+ }),
108
+ [groupName, current, setCurrent, size, disabled, required, invalid],
109
+ );
110
+
111
+ return (
112
+ <SegmentedContext value={context}>
113
+ <div
114
+ role="radiogroup"
115
+ // 字段是 <fieldset> 时名称由 <legend> 给;否则指向字段的标签
116
+ aria-labelledby={field && !field.group ? field.labelId : undefined}
117
+ aria-describedby={field?.describedBy}
118
+ aria-invalid={invalid || undefined}
119
+ aria-required={required || undefined}
120
+ aria-disabled={disabled || undefined}
121
+ {...props}
122
+ data-variant={variant}
123
+ data-size={size}
124
+ className={cn(
125
+ // 各段等宽,按最宽的那一段定;比容器宽时一起收窄,文字截断
126
+ "inline-grid max-w-full auto-cols-fr grid-flow-col p-0.5 text-ink",
127
+ "transition-colors duration-(--duration-fast) ease-standard",
128
+ track[variant],
129
+ controlSize[size],
130
+ // 和输入框一样靠边线说状态
131
+ disabled
132
+ ? "border-line"
133
+ : invalid
134
+ ? "border-danger"
135
+ : "border-line-strong focus-within:border-ink",
136
+ className,
137
+ )}
138
+ />
139
+ </SegmentedContext>
140
+ );
141
+ }
142
+
143
+ const segmentPadding: Record<SegmentedControlSize, string> = {
144
+ sm: "gap-1.5 px-3 [&_svg]:size-3.5",
145
+ md: "gap-2 px-4 [&_svg]:size-4",
146
+ lg: "gap-2.5 px-6 [&_svg]:size-5",
147
+ };
148
+
149
+ type SegmentOwnProps = {
150
+ value: string;
151
+ /** 文字左边的图标。只有图标时要给 `aria-label` */
152
+ icon?: ReactNode;
153
+ disabled?: boolean;
154
+ /** 这一段的可访问名称;不传就是里面的文字 */
155
+ "aria-label"?: string;
156
+ children?: ReactNode;
157
+ };
158
+
159
+ export type SegmentProps = SegmentOwnProps &
160
+ Omit<ComponentProps<"label">, keyof SegmentOwnProps>;
161
+
162
+ /** 分段选择里的一段。`className` 给这一段的外壳。 */
163
+ export function Segment({
164
+ value,
165
+ icon,
166
+ disabled: disabledProp,
167
+ "aria-label": ariaLabel,
168
+ className,
169
+ children,
170
+ ...props
171
+ }: SegmentProps) {
172
+ const context = useContext(SegmentedContext);
173
+ if (!context) {
174
+ throw new Error("<Segment> 必须放在 <SegmentedControl> 里");
175
+ }
176
+ const checked = context.value === value;
177
+ const disabled = disabledProp ?? context.disabled;
178
+
179
+ return (
180
+ <label
181
+ {...props}
182
+ data-selected={checked ? "" : undefined}
183
+ data-disabled={disabled ? "" : undefined}
184
+ className={cn(
185
+ "relative flex min-w-0 items-center justify-center font-medium whitespace-nowrap select-none",
186
+ "transition-colors duration-(--duration-fast) ease-standard",
187
+ // 焦点环画在这一段上,并提到相邻段之上
188
+ "has-focus-visible:z-1 has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-focus",
189
+ segmentPadding[context.size],
190
+ disabled ? "cursor-not-allowed" : "cursor-pointer",
191
+ // 选中不加粗:加粗会让这一段变宽,整条跟着跳。靠的是填充反转
192
+ checked
193
+ ? disabled
194
+ ? "bg-disabled text-on-disabled"
195
+ : "bg-surface-inverse text-ink-inverse"
196
+ : disabled
197
+ ? "text-ink-disabled"
198
+ : "text-ink-secondary hover:bg-ink/5 hover:text-ink",
199
+ className,
200
+ )}
201
+ >
202
+ <input
203
+ type="radio"
204
+ name={context.name}
205
+ value={value}
206
+ checked={checked}
207
+ disabled={disabled}
208
+ required={context.required}
209
+ aria-label={ariaLabel}
210
+ aria-invalid={context.invalid || undefined}
211
+ onChange={() => context.select(value)}
212
+ className="absolute inset-0 size-full cursor-[inherit] appearance-none outline-none"
213
+ />
214
+ {icon}
215
+ {children !== undefined && (
216
+ <span className="pointer-events-none min-w-0 truncate">{children}</span>
217
+ )}
218
+ </label>
219
+ );
220
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ Segment,
3
+ SegmentedControl,
4
+ type SegmentedControlProps,
5
+ type SegmentedControlSize,
6
+ type SegmentedControlVariant,
7
+ type SegmentProps,
8
+ } from "./SegmentedControl";