@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,316 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ type ChangeEvent,
6
+ type ComponentProps,
7
+ type FocusEvent,
8
+ type KeyboardEvent,
9
+ type MouseEvent,
10
+ type PointerEvent,
11
+ } from "react";
12
+ import { useControllableState } from "../../hooks/useControllableState";
13
+ import { Minus } from "../../icons/Minus";
14
+ import { Plus } from "../../icons/Plus";
15
+ import { cn } from "../../lib/cn";
16
+ import { focusRingWithin } from "../../lib/focus-ring";
17
+ import { useFieldControl } from "../field/Field";
18
+
19
+ export type StepperSize = "sm" | "md" | "lg";
20
+
21
+ export type StepperProps = Omit<
22
+ ComponentProps<"input">,
23
+ | "type"
24
+ | "size"
25
+ | "value"
26
+ | "defaultValue"
27
+ | "onChange"
28
+ | "min"
29
+ | "max"
30
+ | "step"
31
+ > & {
32
+ /** 受控的当前值 */
33
+ value?: number;
34
+ /** 非受控时的初始值,缺省取 `min`,没有 `min` 则为 0 */
35
+ defaultValue?: number;
36
+ onValueChange?: (value: number) => void;
37
+ min?: number;
38
+ max?: number;
39
+ /** 每一步的增量,默认 1;可以是小数 */
40
+ step?: number;
41
+ /** 32 / 40 / 56px 高 */
42
+ size?: StepperSize;
43
+ /** 错误态:数字下方出现一条红线。放在 `Field` 里时跟随它的 `error` */
44
+ invalid?: boolean;
45
+ /** 两端按钮的可访问名称 */
46
+ decrementLabel?: string;
47
+ incrementLabel?: string;
48
+ };
49
+
50
+ const boxSize: Record<StepperSize, string> = {
51
+ sm: "h-8 text-sm",
52
+ md: "h-10 text-base",
53
+ lg: "h-14 text-lg",
54
+ };
55
+
56
+ const buttonSize: Record<StepperSize, string> = {
57
+ sm: "w-8",
58
+ md: "w-10",
59
+ lg: "w-14",
60
+ };
61
+
62
+ const fieldSize: Record<StepperSize, string> = {
63
+ sm: "w-12",
64
+ md: "w-14",
65
+ lg: "w-20",
66
+ };
67
+
68
+ const iconSize: Record<StepperSize, number> = { sm: 14, md: 16, lg: 20 };
69
+
70
+ /** 按住多久开始连续加减,以及之后每一步的间隔 */
71
+ const HOLD_DELAY = 400;
72
+ const HOLD_INTERVAL = 80;
73
+
74
+ function decimalsOf(value: number) {
75
+ const text = String(value);
76
+ const dot = text.indexOf(".");
77
+ return dot === -1 ? 0 : text.length - dot - 1;
78
+ }
79
+
80
+ /**
81
+ * 步进器:`−` 钮 + 数字 + `+` 钮,三段紧贴。数字可以直接输入,
82
+ * 也可以用 ↑ ↓(一步)、PageUp / PageDown(十步)、Home / End(上下限)。
83
+ * `className` 给外框,其余属性与 `ref` 给中间的 `<input>`。
84
+ */
85
+ export function Stepper({
86
+ value: valueProp,
87
+ defaultValue,
88
+ onValueChange,
89
+ min,
90
+ max,
91
+ step = 1,
92
+ size = "md",
93
+ invalid: invalidProp,
94
+ decrementLabel = "减少",
95
+ incrementLabel = "增加",
96
+ id: idProp,
97
+ disabled: disabledProp,
98
+ required: requiredProp,
99
+ readOnly = false,
100
+ "aria-describedby": describedByProp,
101
+ onKeyDown,
102
+ onBlur,
103
+ className,
104
+ ...props
105
+ }: StepperProps) {
106
+ const { id, disabled, required, invalid, ...field } = useFieldControl({
107
+ id: idProp,
108
+ disabled: disabledProp,
109
+ required: requiredProp,
110
+ invalid: invalidProp,
111
+ "aria-describedby": describedByProp,
112
+ });
113
+
114
+ // 小数步长会带出 0.1 + 0.2 这样的浮点尾数,统一按步长的小数位数取整
115
+ const precision = Math.max(
116
+ decimalsOf(step),
117
+ decimalsOf(min ?? 0),
118
+ decimalsOf(max ?? 0),
119
+ );
120
+ const normalize = (next: number) => {
121
+ const clamped = Math.min(Math.max(next, min ?? -Infinity), max ?? Infinity);
122
+ return Number(clamped.toFixed(precision));
123
+ };
124
+
125
+ const [value, setValue] = useControllableState<number>({
126
+ value: valueProp,
127
+ defaultValue: defaultValue ?? normalize(0),
128
+ onChange: onValueChange,
129
+ });
130
+
131
+ // 输入到一半的文字:不为 null 时输入框显示它,失焦或回车时才变成数值
132
+ const [draft, setDraft] = useState<string | null>(null);
133
+
134
+ const atMin = min !== undefined && value <= min;
135
+ const atMax = max !== undefined && value >= max;
136
+ const locked = disabled || readOnly;
137
+
138
+ /** 加减一段;值真的变了才返回 true,按住连续加减靠它在到头时停表 */
139
+ const stepBy = (delta: number) => {
140
+ const typed = draft === null || draft.trim() === "" ? NaN : Number(draft);
141
+ const base = Number.isFinite(typed) ? typed : value;
142
+ const next = normalize(base + delta);
143
+ setDraft(null);
144
+ if (next === value) return false;
145
+ setValue(next);
146
+ return true;
147
+ };
148
+
149
+ const commitDraft = () => {
150
+ if (draft === null) return;
151
+ const typed = draft.trim() === "" ? NaN : Number(draft);
152
+ setDraft(null);
153
+ // 清空或只敲了一个负号:还原成原来的值
154
+ if (!Number.isFinite(typed)) return;
155
+ setValue(normalize(typed));
156
+ };
157
+
158
+ // 定时器里要用到最新的值和回调
159
+ const stepRef = useRef(stepBy);
160
+ useEffect(() => {
161
+ stepRef.current = stepBy;
162
+ });
163
+
164
+ const holdRef = useRef<{ delay?: number; repeat?: number }>({});
165
+ const release = () => {
166
+ window.clearTimeout(holdRef.current.delay);
167
+ window.clearInterval(holdRef.current.repeat);
168
+ holdRef.current = {};
169
+ };
170
+ useEffect(() => release, []);
171
+
172
+ const press = (event: PointerEvent<HTMLButtonElement>, delta: number) => {
173
+ // 只认主键;触屏和笔没有 button 的区分
174
+ if (event.button > 0) return;
175
+ release();
176
+ if (!stepRef.current(delta)) return;
177
+ holdRef.current.delay = window.setTimeout(() => {
178
+ holdRef.current.repeat = window.setInterval(() => {
179
+ if (!stepRef.current(delta)) release();
180
+ }, HOLD_INTERVAL);
181
+ }, HOLD_DELAY);
182
+ };
183
+
184
+ // 指针按下时已经走过一步;click 只留给键盘和读屏(它们触发的 click 没有次数)
185
+ const click = (event: MouseEvent<HTMLButtonElement>, delta: number) => {
186
+ if (event.detail === 0) stepBy(delta);
187
+ };
188
+
189
+ const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
190
+ const raw = event.target.value;
191
+ // 只收数字、负号和小数点,其余按键不进输入框
192
+ if (/^-?\d*\.?\d*$/.test(raw)) setDraft(raw);
193
+ };
194
+
195
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
196
+ onKeyDown?.(event);
197
+ if (event.defaultPrevented || locked) return;
198
+
199
+ switch (event.key) {
200
+ case "ArrowUp":
201
+ stepBy(step);
202
+ break;
203
+ case "ArrowDown":
204
+ stepBy(-step);
205
+ break;
206
+ case "PageUp":
207
+ stepBy(step * 10);
208
+ break;
209
+ case "PageDown":
210
+ stepBy(-step * 10);
211
+ break;
212
+ case "Home":
213
+ if (min === undefined) return;
214
+ setDraft(null);
215
+ setValue(min);
216
+ break;
217
+ case "End":
218
+ if (max === undefined) return;
219
+ setDraft(null);
220
+ setValue(max);
221
+ break;
222
+ case "Enter":
223
+ // 不拦回车:所在的表单照常提交,只是先把输入到一半的数落定
224
+ commitDraft();
225
+ return;
226
+ default:
227
+ return;
228
+ }
229
+ event.preventDefault();
230
+ };
231
+
232
+ const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
233
+ onBlur?.(event);
234
+ commitDraft();
235
+ };
236
+
237
+ const buttonClass = (inactive: boolean) =>
238
+ cn(
239
+ "flex shrink-0 items-center justify-center select-none",
240
+ "transition-colors duration-(--duration-fast) ease-standard",
241
+ buttonSize[size],
242
+ inactive
243
+ ? "cursor-not-allowed bg-disabled text-on-disabled"
244
+ : "bg-control text-on-control hover:bg-control-hover active:bg-control-pressed",
245
+ );
246
+
247
+ return (
248
+ <div
249
+ data-size={size}
250
+ data-invalid={invalid ? "" : undefined}
251
+ className={cn(
252
+ "inline-flex items-stretch",
253
+ focusRingWithin,
254
+ boxSize[size],
255
+ className,
256
+ )}
257
+ >
258
+ <button
259
+ type="button"
260
+ // 键盘用户在输入框里按方向键,按钮不占 Tab 顺序
261
+ tabIndex={-1}
262
+ aria-label={decrementLabel}
263
+ disabled={locked || atMin}
264
+ onPointerDown={(event) => press(event, -step)}
265
+ onPointerUp={release}
266
+ onPointerLeave={release}
267
+ onPointerCancel={release}
268
+ onClick={(event) => click(event, -step)}
269
+ className={buttonClass(locked || atMin)}
270
+ >
271
+ <Minus size={iconSize[size]} />
272
+ </button>
273
+ <input
274
+ {...props}
275
+ id={id}
276
+ type="text"
277
+ role="spinbutton"
278
+ inputMode={precision > 0 ? "decimal" : "numeric"}
279
+ autoComplete="off"
280
+ value={draft ?? String(value)}
281
+ disabled={disabled}
282
+ required={required}
283
+ readOnly={readOnly}
284
+ aria-valuenow={value}
285
+ aria-valuemin={min}
286
+ aria-valuemax={max}
287
+ aria-invalid={invalid || undefined}
288
+ aria-describedby={field["aria-describedby"]}
289
+ onChange={handleChange}
290
+ onKeyDown={handleKeyDown}
291
+ onBlur={handleBlur}
292
+ className={cn(
293
+ "min-w-0 flex-1 border-y-2 border-transparent bg-surface-sunken text-center font-tech font-bold tabular-nums outline-none",
294
+ "disabled:cursor-not-allowed",
295
+ fieldSize[size],
296
+ disabled ? "text-ink-disabled" : "text-ink",
297
+ invalid && !disabled && "border-b-danger",
298
+ )}
299
+ />
300
+ <button
301
+ type="button"
302
+ tabIndex={-1}
303
+ aria-label={incrementLabel}
304
+ disabled={locked || atMax}
305
+ onPointerDown={(event) => press(event, step)}
306
+ onPointerUp={release}
307
+ onPointerLeave={release}
308
+ onPointerCancel={release}
309
+ onClick={(event) => click(event, step)}
310
+ className={buttonClass(locked || atMax)}
311
+ >
312
+ <Plus size={iconSize[size]} />
313
+ </button>
314
+ </div>
315
+ );
316
+ }
@@ -0,0 +1 @@
1
+ export { Stepper, type StepperProps, type StepperSize } from "./Stepper";
@@ -0,0 +1,302 @@
1
+ import {
2
+ Children,
3
+ createContext,
4
+ useContext,
5
+ type ComponentProps,
6
+ type MouseEventHandler,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { Check } from "../../icons/Check";
10
+ import { StatusDanger } from "../../icons/StatusDanger";
11
+ import { cn } from "../../lib/cn";
12
+ import { focusRingWithin } from "../../lib/focus-ring";
13
+ import { LinkElement, type LinkRender } from "../link-element/LinkElement";
14
+
15
+ export type StepsOrientation = "horizontal" | "vertical";
16
+ export type StepsSize = "sm" | "md";
17
+ export type StepStatus = "done" | "current" | "upcoming";
18
+
19
+ /** 读屏听到的状态。未到的步骤不另说 */
20
+ export type StepStatusLabels = {
21
+ done: string;
22
+ current: string;
23
+ invalid: string;
24
+ };
25
+
26
+ const defaultLabels: StepStatusLabels = {
27
+ done: "已完成",
28
+ current: "当前步骤",
29
+ invalid: "有误",
30
+ };
31
+
32
+ type StepContextValue = {
33
+ index: number;
34
+ status: StepStatus;
35
+ orientation: StepsOrientation;
36
+ size: StepsSize;
37
+ labels: StepStatusLabels;
38
+ };
39
+
40
+ const StepContext = createContext<StepContextValue | null>(null);
41
+
42
+ export type StepsProps = Omit<ComponentProps<"ol">, "aria-label"> & {
43
+ /** 这是哪件事的步骤,比如"建站流程" */
44
+ "aria-label": string;
45
+ /** 走到了第几步,从 0 起。等于步数时全部是已完成 */
46
+ current: number;
47
+ /**
48
+ * - `horizontal` 横排,默认。所在的容器窄于 28rem 时自动改成竖排;
49
+ * - `vertical` 一直竖排。
50
+ */
51
+ orientation?: StepsOrientation;
52
+ size?: StepsSize;
53
+ /** 换掉读屏听到的那几句状态 */
54
+ statusLabels?: Partial<StepStatusLabels>;
55
+ /** 直接放 `Step` */
56
+ children: ReactNode;
57
+ };
58
+
59
+ /*
60
+ * 竖排是底子,横排是在容器够宽(@md,28rem)时盖上去的一层:
61
+ * 这样横排在窄容器里自己就退回竖排,不用另写一遍。
62
+ */
63
+ const listLayout: Record<StepsOrientation, string> = {
64
+ vertical: "flex-col",
65
+ horizontal: "flex-col @md:flex-row",
66
+ };
67
+
68
+ const itemLayout: Record<StepsOrientation, string> = {
69
+ vertical: "gap-3",
70
+ horizontal: "gap-3 @md:flex-1 @md:flex-col @md:gap-2",
71
+ };
72
+
73
+ const railLayout: Record<StepsOrientation, string> = {
74
+ vertical: "flex-col",
75
+ horizontal: "flex-col @md:flex-row",
76
+ };
77
+
78
+ /* 线和节点之间留 8px,不从节点后面穿过 */
79
+ const lineLayout: Record<StepsOrientation, string> = {
80
+ vertical: "my-2 min-h-4 w-0.5",
81
+ horizontal:
82
+ "my-2 min-h-4 w-0.5 @md:mx-2 @md:my-0 @md:h-0.5 @md:min-h-0 @md:w-auto",
83
+ };
84
+
85
+ const bodyLayout: Record<StepsOrientation, string> = {
86
+ vertical: "pb-4 group-last/step:pb-0",
87
+ horizontal: "pb-4 group-last/step:pb-0 @md:pr-4 @md:pb-0",
88
+ };
89
+
90
+ /**
91
+ * 步骤条:一件事分几步做,一共几步、走到了哪一步。里面只放 `Step`。
92
+ * 每一步的状态由 `current` 和它的位置算出来。
93
+ *
94
+ * `className` 给最外面的那层(它是量宽度的容器),其余属性给里面的 `<ol>`。
95
+ */
96
+ export function Steps({
97
+ current,
98
+ orientation = "horizontal",
99
+ size = "md",
100
+ statusLabels,
101
+ className,
102
+ children,
103
+ ...props
104
+ }: StepsProps) {
105
+ const labels = { ...defaultLabels, ...statusLabels };
106
+ const steps = Children.toArray(children);
107
+
108
+ return (
109
+ <div
110
+ data-orientation={orientation}
111
+ data-size={size}
112
+ className={cn("@container w-full text-ink", className)}
113
+ >
114
+ <ol {...props} className={cn("flex", listLayout[orientation])}>
115
+ {steps.map((step, index) => (
116
+ <StepContext
117
+ // 步骤是按位置认的:序号、状态都从位置来
118
+ key={index}
119
+ value={{
120
+ index,
121
+ status:
122
+ index < current
123
+ ? "done"
124
+ : index === current
125
+ ? "current"
126
+ : "upcoming",
127
+ orientation,
128
+ size,
129
+ labels,
130
+ }}
131
+ >
132
+ {step}
133
+ </StepContext>
134
+ ))}
135
+ </ol>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ type StepOwnProps = {
141
+ title: ReactNode;
142
+ /** 标题下的说明 */
143
+ description?: ReactNode;
144
+ /** 这一步有东西没填对:节点换成警示图标,读屏多听到一句"有误" */
145
+ invalid?: boolean;
146
+ /** 传了就可以点(回到做过的步骤):标题是链接,点击范围铺满这一步 */
147
+ href?: string;
148
+ target?: string;
149
+ rel?: string;
150
+ /** 用这个元素代替标题里的 `<a>`(路由库的链接组件) */
151
+ render?: LinkRender;
152
+ /** 不是链接、但要能点时用它:标题是按钮 */
153
+ onClick?: MouseEventHandler<HTMLElement>;
154
+ };
155
+
156
+ export type StepProps = StepOwnProps &
157
+ Omit<ComponentProps<"li">, keyof StepOwnProps | "children">;
158
+
159
+ const nodeSize: Record<StepsSize, string> = {
160
+ sm: "size-5 text-micro [&_svg]:size-3",
161
+ md: "size-6 text-xs [&_svg]:size-3.5",
162
+ };
163
+
164
+ const nodeClass: Record<StepStatus, string> = {
165
+ // 墨底黄勾:和选中的复选框是同一个逻辑
166
+ done: "bg-surface-inverse text-accent-ink-inverse",
167
+ // 描一圈最细的线:黄色对白底立不住。颜色跟着文字走,暗色下是近白
168
+ current: "border border-ink bg-action font-bold text-on-action",
169
+ upcoming: "border border-line-strong text-ink-secondary",
170
+ };
171
+
172
+ const titleSize: Record<StepsSize, string> = {
173
+ sm: "text-xs",
174
+ md: "text-sm",
175
+ };
176
+
177
+ const titleClass: Record<StepStatus, string> = {
178
+ done: "font-medium text-ink",
179
+ current: "font-bold text-ink",
180
+ upcoming: "font-medium text-ink-secondary",
181
+ };
182
+
183
+ export function Step({
184
+ title,
185
+ description,
186
+ invalid = false,
187
+ href,
188
+ target,
189
+ rel,
190
+ render,
191
+ onClick,
192
+ className,
193
+ ...props
194
+ }: StepProps) {
195
+ const context = useContext(StepContext);
196
+ if (!context) {
197
+ throw new Error("<Step> 必须放在 <Steps> 里");
198
+ }
199
+ const { index, status, orientation, size, labels } = context;
200
+ const isLink = href !== undefined || render !== undefined;
201
+ const interactive = isLink || onClick !== undefined;
202
+
203
+ // 读屏听到的状态:节点和线都是画给眼睛看的
204
+ const spoken = [
205
+ status === "done"
206
+ ? labels.done
207
+ : status === "current"
208
+ ? labels.current
209
+ : "",
210
+ invalid ? labels.invalid : "",
211
+ ]
212
+ .filter(Boolean)
213
+ .join(",");
214
+
215
+ // 焦点环画在整一步上;点击范围用伪元素铺满这一步
216
+ const hit =
217
+ "text-left outline-none after:absolute after:inset-0 after:content-[''] hover:underline hover:underline-offset-4";
218
+
219
+ const heading = (
220
+ <>
221
+ {spoken !== "" && <span className="sr-only">{`${spoken}:`}</span>}
222
+ {title}
223
+ </>
224
+ );
225
+
226
+ return (
227
+ <li
228
+ {...props}
229
+ data-status={status}
230
+ data-invalid={invalid ? "" : undefined}
231
+ aria-current={status === "current" ? "step" : undefined}
232
+ className={cn(
233
+ "group/step relative flex min-w-0",
234
+ itemLayout[orientation],
235
+ interactive && focusRingWithin,
236
+ className,
237
+ )}
238
+ >
239
+ <span
240
+ aria-hidden="true"
241
+ className={cn("flex shrink-0 items-center", railLayout[orientation])}
242
+ >
243
+ <span
244
+ className={cn(
245
+ "flex shrink-0 items-center justify-center font-tech leading-none tabular-nums",
246
+ nodeSize[size],
247
+ invalid ? "border border-danger text-danger" : nodeClass[status],
248
+ )}
249
+ >
250
+ {invalid ? (
251
+ <StatusDanger />
252
+ ) : status === "done" ? (
253
+ <Check />
254
+ ) : (
255
+ String(index + 1).padStart(2, "0")
256
+ )}
257
+ </span>
258
+ {/* 这一步走完了,通向下一步的线才是实的 */}
259
+ <span
260
+ className={cn(
261
+ "flex-1 group-last/step:hidden",
262
+ lineLayout[orientation],
263
+ status === "done" ? "bg-ink" : "bg-line-strong",
264
+ )}
265
+ />
266
+ </span>
267
+ <div className={cn("min-w-0 flex-1", bodyLayout[orientation])}>
268
+ <div
269
+ className={cn(
270
+ "wrap-anywhere",
271
+ titleSize[size],
272
+ titleClass[status],
273
+ // 竖排时标题的第一行对齐节点的中线
274
+ size === "md" ? "leading-6" : "leading-5",
275
+ )}
276
+ >
277
+ {isLink ? (
278
+ <LinkElement
279
+ render={render}
280
+ href={href}
281
+ target={target}
282
+ rel={rel}
283
+ onClick={onClick}
284
+ className={hit}
285
+ >
286
+ {heading}
287
+ </LinkElement>
288
+ ) : onClick ? (
289
+ <button type="button" onClick={onClick} className={hit}>
290
+ {heading}
291
+ </button>
292
+ ) : (
293
+ heading
294
+ )}
295
+ </div>
296
+ {description !== undefined && (
297
+ <div className="mt-0.5 text-xs text-ink-secondary">{description}</div>
298
+ )}
299
+ </div>
300
+ </li>
301
+ );
302
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ Step,
3
+ Steps,
4
+ type StepProps,
5
+ type StepStatus,
6
+ type StepStatusLabels,
7
+ type StepsOrientation,
8
+ type StepsProps,
9
+ type StepsSize,
10
+ } from "./Steps";