@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,502 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ChangeEvent,
8
+ type ClipboardEvent,
9
+ type ComponentProps,
10
+ type KeyboardEvent,
11
+ type MouseEvent,
12
+ } from "react";
13
+ import { useControllableState } from "../../hooks/useControllableState";
14
+ import { Close } from "../../icons/Close";
15
+ import { StatusDanger } from "../../icons/StatusDanger";
16
+ import { cn } from "../../lib/cn";
17
+ import { mergeRefs } from "../../lib/merge-refs";
18
+ import { useFieldControl } from "../field/Field";
19
+ import {
20
+ controlBox,
21
+ controlElement,
22
+ controlIconSize,
23
+ type ControlSize,
24
+ type ControlVariant,
25
+ } from "../input/control-box";
26
+ import {
27
+ multiValueBox,
28
+ valueChip,
29
+ valueChipRemove,
30
+ valueChipSize,
31
+ valueInputHeight,
32
+ } from "../input/value-chip";
33
+
34
+ export type TagInputSize = ControlSize;
35
+ export type TagInputVariant = ControlVariant;
36
+
37
+ /** 没加成的原因:已经有了、到了上限、没过 `validate` */
38
+ export type TagInputRejectReason = "duplicate" | "max" | "invalid";
39
+
40
+ /** 读屏听到的那几句话 */
41
+ export type TagInputLabels = {
42
+ /** 框里现在有哪些,作为输入框的补充说明 */
43
+ summary: (tags: readonly string[]) => string;
44
+ added: (tag: string) => string;
45
+ removed: (tag: string) => string;
46
+ duplicate: (tag: string) => string;
47
+ max: (max: number) => string;
48
+ };
49
+
50
+ const defaultLabels: TagInputLabels = {
51
+ summary: (tags) =>
52
+ tags.length === 0
53
+ ? "还没有标签"
54
+ : `已有 ${tags.length} 个标签:${tags.join("、")}`,
55
+ added: (tag) => `已添加${tag}`,
56
+ removed: (tag) => `已移除${tag}`,
57
+ duplicate: (tag) => `已经有${tag}了`,
58
+ max: (max) => `最多 ${max} 个`,
59
+ };
60
+
61
+ type OwnProps = {
62
+ /** 受控的值 */
63
+ value?: string[];
64
+ /** 非受控时的初始值 */
65
+ defaultValue?: string[];
66
+ onValueChange?: (value: string[]) => void;
67
+ /** 表单字段名:每个标签一个同名的隐藏字段 */
68
+ name?: string;
69
+ /** 最多几个。传了右端会出现一个计数 */
70
+ max?: number;
71
+ /**
72
+ * 这个词能不能加。返回一句话就是不让加,这句话会交给 `onReject`;
73
+ * 返回空的就是可以
74
+ */
75
+ validate?: (
76
+ text: string,
77
+ current: readonly string[],
78
+ ) => string | null | undefined | false;
79
+ /** 打出这些字符就提交。默认是半角和全角的逗号;回车总是提交 */
80
+ separators?: readonly string[];
81
+ /** 离开输入框时把没提交的字也加进去,默认开 */
82
+ commitOnBlur?: boolean;
83
+ /** 一个词没加成:已经有了、到了上限、没过 `validate`(这时第三个参数是那句话) */
84
+ onReject?: (
85
+ text: string,
86
+ reason: TagInputRejectReason,
87
+ message?: string,
88
+ ) => void;
89
+ /**
90
+ * 外框的画法,和输入框相同:
91
+ * - `sunken` 凹陷的底 + 一条底边线,默认;
92
+ * - `outline` 四边描边,放在凹陷底色的区域里时用。
93
+ */
94
+ variant?: TagInputVariant;
95
+ /** 32 / 40 / 56px:这是最小高度,标签多了会换行、外框长高 */
96
+ size?: TagInputSize;
97
+ /** 错误态。放在 `Field` 里时跟随它的 `error` */
98
+ invalid?: boolean;
99
+ /** 小块上删除叉的可访问名称,拿到的是这个标签。默认"移除某某" */
100
+ removeLabel?: (tag: string) => string;
101
+ /** 读屏听到的那几句话,换哪句传哪句 */
102
+ labels?: Partial<TagInputLabels>;
103
+ /** 给外框 */
104
+ className?: string;
105
+ };
106
+
107
+ export type TagInputProps = OwnProps &
108
+ Omit<
109
+ ComponentProps<"input">,
110
+ keyof OwnProps | "defaultValue" | "max" | "size" | "type" | "value"
111
+ >;
112
+
113
+ const DEFAULT_SEPARATORS = [",", ","] as const;
114
+
115
+ /*
116
+ * 右端的错误图标和计数贴着最后一行:这一格的高度是外框的最小高度减去 2px 的边线,
117
+ * 只有一行时它正好居中,小块换了行它留在底下(和多行文本的计数同一个位置)
118
+ */
119
+ const trailingRow: Record<TagInputSize, string> = {
120
+ sm: "h-[30px]",
121
+ md: "h-[38px]",
122
+ lg: "h-[54px]",
123
+ };
124
+
125
+ /** 重复的那个小块闪多久 */
126
+ const FLASH_MS = 400;
127
+
128
+ /**
129
+ * 标签输入:自己打出来的一串短词。回车或者打出分隔符就加一个,粘贴一串会拆开。
130
+ * 要从现成的选项里挑,用 `Combobox` 的 `multiple`。
131
+ *
132
+ * `className` 给外框,其余属性与 `ref` 给里面的 `<input>`。
133
+ * 每个标签输入都要有标签:放进 `Field`,或者自己传 `aria-label`。
134
+ */
135
+ export function TagInput({
136
+ value,
137
+ defaultValue,
138
+ onValueChange,
139
+ name,
140
+ max,
141
+ validate,
142
+ separators = DEFAULT_SEPARATORS,
143
+ commitOnBlur = true,
144
+ onReject,
145
+ variant = "sunken",
146
+ size = "md",
147
+ invalid: invalidProp,
148
+ removeLabel = (tag) => `移除${tag}`,
149
+ labels: labelsProp,
150
+ id: idProp,
151
+ disabled: disabledProp,
152
+ required: requiredProp,
153
+ readOnly = false,
154
+ placeholder,
155
+ "aria-describedby": describedByProp,
156
+ className,
157
+ ref,
158
+ onKeyDown,
159
+ onBlur,
160
+ onPaste,
161
+ onCompositionEnd,
162
+ ...props
163
+ }: TagInputProps) {
164
+ const { id, disabled, required, invalid, ...field } = useFieldControl({
165
+ id: idProp,
166
+ disabled: disabledProp,
167
+ required: requiredProp,
168
+ invalid: invalidProp,
169
+ "aria-describedby": describedByProp,
170
+ });
171
+ const labels = { ...defaultLabels, ...labelsProp };
172
+ const summaryId = useId();
173
+ const inputRef = useRef<HTMLInputElement>(null);
174
+ const boxRef = useRef<HTMLDivElement>(null);
175
+
176
+ const [tags, setTags] = useControllableState<string[]>({
177
+ value,
178
+ defaultValue: defaultValue ?? [],
179
+ onChange: onValueChange,
180
+ });
181
+ const [text, setText] = useState("");
182
+ const [flash, setFlash] = useState<string | null>(null);
183
+ const [message, setMessage] = useState("");
184
+ const full = max !== undefined && tags.length >= max;
185
+
186
+ useEffect(() => {
187
+ if (flash === null) return;
188
+ const timer = setTimeout(() => setFlash(null), FLASH_MS);
189
+ return () => clearTimeout(timer);
190
+ }, [flash]);
191
+
192
+ // 删掉一个之后焦点落到哪:等这一次渲染完,旁边那个小块才是"旁边那个"
193
+ const pendingFocus = useRef<number | "input" | null>(null);
194
+ useEffect(() => {
195
+ const target = pendingFocus.current;
196
+ if (target === null) return;
197
+ pendingFocus.current = null;
198
+ if (target === "input") inputRef.current?.focus();
199
+ else focusChip(target);
200
+ });
201
+
202
+ const chipButtons = () =>
203
+ boxRef.current?.querySelectorAll<HTMLButtonElement>("[data-tag] button") ??
204
+ [];
205
+ const focusChip = (index: number) => {
206
+ const buttons = chipButtons();
207
+ const button = buttons[Math.min(index, buttons.length - 1)];
208
+ if (button) button.focus();
209
+ else inputRef.current?.focus();
210
+ };
211
+
212
+ const splitter = useMemo(() => {
213
+ const escaped = [...separators, "\n", "\r"].map((separator) =>
214
+ separator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
215
+ );
216
+ return new RegExp(escaped.join("|"));
217
+ }, [separators]);
218
+
219
+ /**
220
+ * 把几个词加进去。返回该留在框里的字:只提交了一个词、它又没加成时留着,方便改;
221
+ * 一次提交好几个(粘贴)时不留
222
+ */
223
+ const commit = (pieces: readonly string[]) => {
224
+ const words = pieces.map((piece) => piece.trim()).filter(Boolean);
225
+ let next = tags;
226
+ let said = "";
227
+ let kept = "";
228
+ for (const word of words) {
229
+ let reason: TagInputRejectReason | null = null;
230
+ let problem: string | undefined;
231
+ if (next.includes(word)) {
232
+ reason = "duplicate";
233
+ said = labels.duplicate(word);
234
+ setFlash(word);
235
+ } else if (max !== undefined && next.length >= max) {
236
+ reason = "max";
237
+ said = labels.max(max);
238
+ } else {
239
+ const verdict = validate?.(word, next);
240
+ if (verdict) {
241
+ reason = "invalid";
242
+ problem = verdict;
243
+ said = verdict;
244
+ }
245
+ }
246
+
247
+ if (reason === null) {
248
+ next = [...next, word];
249
+ said = labels.added(word);
250
+ } else {
251
+ onReject?.(word, reason, problem);
252
+ if (words.length === 1) kept = word;
253
+ }
254
+ }
255
+ if (next !== tags) setTags(next);
256
+ if (said) setMessage(said);
257
+ return kept;
258
+ };
259
+
260
+ const remove = (index: number, focus: number | "input") => {
261
+ const tag = tags[index];
262
+ if (tag === undefined) return;
263
+ pendingFocus.current = focus;
264
+ setTags(tags.filter((_, position) => position !== index));
265
+ setMessage(labels.removed(tag));
266
+ };
267
+
268
+ /** 框里的字变了:里面有分隔符就拆开提交。不看按的是哪个键——输入法、粘贴走的都是这里 */
269
+ const take = (next: string) => {
270
+ if (!splitter.test(next)) {
271
+ setText(next);
272
+ return;
273
+ }
274
+ setText(commit(next.split(splitter)));
275
+ };
276
+
277
+ const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
278
+ // 输入法还在组字:拼音里的逗号、候选词都还不算数,等它组完
279
+ if ((event.nativeEvent as InputEvent).isComposing) {
280
+ setText(event.target.value);
281
+ return;
282
+ }
283
+ take(event.target.value);
284
+ };
285
+
286
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
287
+ onKeyDown?.(event);
288
+ if (event.defaultPrevented || readOnly) return;
289
+ // 输入法正在组字时的回车是在选字,不是提交
290
+ // oxlint-disable-next-line typescript/no-deprecated -- Safari 上确认选字的那一下回车,isComposing 已经是 false,只有 keyCode 还是 229
291
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
292
+ const input = event.currentTarget;
293
+
294
+ if (event.key === "Enter") {
295
+ // 框里空着的回车留给表单:该提交就提交
296
+ if (text.trim() === "") return;
297
+ event.preventDefault();
298
+ setText(commit([text]));
299
+ return;
300
+ }
301
+
302
+ const atStart = input.selectionStart === 0 && input.selectionEnd === 0;
303
+ if (event.key === "Backspace" && text === "" && tags.length > 0) {
304
+ event.preventDefault();
305
+ remove(tags.length - 1, "input");
306
+ } else if (event.key === "ArrowLeft" && atStart && tags.length > 0) {
307
+ event.preventDefault();
308
+ focusChip(tags.length - 1);
309
+ }
310
+ };
311
+
312
+ const handleChipKeyDown = (
313
+ event: KeyboardEvent<HTMLButtonElement>,
314
+ index: number,
315
+ ) => {
316
+ switch (event.key) {
317
+ case "ArrowLeft":
318
+ event.preventDefault();
319
+ if (index > 0) focusChip(index - 1);
320
+ break;
321
+ case "ArrowRight":
322
+ event.preventDefault();
323
+ if (index < tags.length - 1) focusChip(index + 1);
324
+ else inputRef.current?.focus();
325
+ break;
326
+ case "Backspace":
327
+ case "Delete":
328
+ event.preventDefault();
329
+ // 删掉之后这个位置上是原来的下一个;它是最后一个时落到前一个上
330
+ remove(index, tags.length === 1 ? "input" : index);
331
+ break;
332
+ }
333
+ };
334
+
335
+ const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
336
+ onPaste?.(event);
337
+ if (event.defaultPrevented || readOnly) return;
338
+ const pasted = event.clipboardData.getData("text");
339
+ // 单行输入框会把换行吃掉:带换行或分隔符的自己拆
340
+ if (!splitter.test(pasted)) return;
341
+ event.preventDefault();
342
+ const input = event.currentTarget;
343
+ const before = text.slice(0, input.selectionStart ?? text.length);
344
+ const after = text.slice(input.selectionEnd ?? text.length);
345
+ setText(commit(`${before}${pasted}${after}`.split(splitter)));
346
+ };
347
+
348
+ const editable = !disabled && !readOnly;
349
+
350
+ return (
351
+ <div
352
+ ref={boxRef}
353
+ data-variant={variant}
354
+ data-size={size}
355
+ data-invalid={invalid ? "" : undefined}
356
+ data-disabled={disabled ? "" : undefined}
357
+ // 点框里空着的地方也是点输入框
358
+ onMouseDown={(event: MouseEvent<HTMLDivElement>) => {
359
+ if ((event.target as Element).closest("button, input")) return;
360
+ event.preventDefault();
361
+ inputRef.current?.focus();
362
+ }}
363
+ className={cn(
364
+ controlBox({ variant, invalid, disabled, readOnly }),
365
+ "items-center",
366
+ multiValueBox[size],
367
+ className,
368
+ )}
369
+ >
370
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1 py-1 pl-2">
371
+ {tags.map((tag, index) => (
372
+ <span
373
+ key={tag}
374
+ data-tag=""
375
+ data-flash={flash === tag ? "" : undefined}
376
+ className={cn(
377
+ valueChip,
378
+ valueChipSize[size],
379
+ "transition-colors duration-(--duration-fast) ease-standard",
380
+ // 重复时闪一下:填充反转。不用黄色——这不是"选中"
381
+ "data-flash:bg-surface-inverse data-flash:text-ink-inverse",
382
+ disabled && "text-ink-disabled",
383
+ !editable && "pr-2",
384
+ )}
385
+ >
386
+ <span className="min-w-0 truncate">{tag}</span>
387
+ {editable && (
388
+ <button
389
+ type="button"
390
+ // 整个控件只占一个 Tab 停靠点:小块用方向键走
391
+ tabIndex={-1}
392
+ aria-label={removeLabel(tag)}
393
+ onClick={(event) =>
394
+ // 键盘触发的(回车、空格)焦点留在小块这一排;鼠标点的回到输入框
395
+ remove(
396
+ index,
397
+ event.detail === 0 && tags.length > 1 ? index : "input",
398
+ )
399
+ }
400
+ onKeyDown={(event) => handleChipKeyDown(event, index)}
401
+ className={cn(
402
+ valueChipRemove,
403
+ "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus",
404
+ flash === tag && "text-ink-inverse",
405
+ )}
406
+ >
407
+ <Close size={12} />
408
+ </button>
409
+ )}
410
+ </span>
411
+ ))}
412
+ <input
413
+ {...props}
414
+ ref={mergeRefs(inputRef, ref)}
415
+ id={id}
416
+ type="text"
417
+ value={text}
418
+ disabled={disabled}
419
+ readOnly={readOnly}
420
+ // 已经有标签了就不算"没填"
421
+ required={required && tags.length === 0}
422
+ aria-required={required || undefined}
423
+ // 已经有小块了,就不再显示提示
424
+ placeholder={tags.length === 0 ? placeholder : undefined}
425
+ aria-invalid={invalid || undefined}
426
+ aria-describedby={
427
+ [summaryId, field["aria-describedby"]].filter(Boolean).join(" ") ||
428
+ undefined
429
+ }
430
+ autoComplete="off"
431
+ onChange={handleChange}
432
+ onKeyDown={handleKeyDown}
433
+ onPaste={handlePaste}
434
+ onCompositionEnd={(event) => {
435
+ onCompositionEnd?.(event);
436
+ // 组完了:这时框里的字才算数
437
+ take(event.currentTarget.value);
438
+ }}
439
+ onBlur={(event) => {
440
+ onBlur?.(event);
441
+ if (commitOnBlur && editable && text.trim() !== "") {
442
+ setText(commit([text]));
443
+ }
444
+ }}
445
+ className={cn(
446
+ controlElement,
447
+ "min-w-12 px-1",
448
+ valueInputHeight[size],
449
+ )}
450
+ />
451
+ </div>
452
+ {(invalid || max !== undefined) && (
453
+ <span
454
+ className={cn(
455
+ "flex shrink-0 items-center gap-2 self-end pr-3 pl-1",
456
+ trailingRow[size],
457
+ )}
458
+ >
459
+ {invalid && (
460
+ <StatusDanger
461
+ size={controlIconSize[size]}
462
+ className="shrink-0 text-danger"
463
+ />
464
+ )}
465
+ {max !== undefined && (
466
+ <span
467
+ aria-hidden="true"
468
+ data-count=""
469
+ className={cn(
470
+ "font-tech text-xs tabular-nums",
471
+ disabled
472
+ ? "text-ink-disabled"
473
+ : full
474
+ ? "text-ink"
475
+ : "text-ink-secondary",
476
+ )}
477
+ >
478
+ {`${tags.length} / ${max}`}
479
+ </span>
480
+ )}
481
+ </span>
482
+ )}
483
+
484
+ <span id={summaryId} className="sr-only">
485
+ {labels.summary(tags)}
486
+ </span>
487
+ <span aria-live="polite" className="sr-only">
488
+ {message}
489
+ </span>
490
+ {name !== undefined &&
491
+ tags.map((tag) => (
492
+ <input
493
+ key={tag}
494
+ type="hidden"
495
+ name={name}
496
+ value={tag}
497
+ disabled={disabled}
498
+ />
499
+ ))}
500
+ </div>
501
+ );
502
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ TagInput,
3
+ type TagInputLabels,
4
+ type TagInputProps,
5
+ type TagInputRejectReason,
6
+ type TagInputSize,
7
+ type TagInputVariant,
8
+ } from "./TagInput";
@@ -0,0 +1,53 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export type TermTone = "accent" | "info" | "success" | "warning" | "danger";
5
+
6
+ export type TermProps = ComponentProps<"strong"> & {
7
+ /**
8
+ * 颜色代表的含义。同一个产品里"颜色 → 含义"的对应必须唯一,并写成一张表。
9
+ * 默认 `accent`:数值与一般的强调。
10
+ */
11
+ tone?: TermTone;
12
+ /** 词前的小图标,跟着文字的大小与颜色走 */
13
+ icon?: ReactNode;
14
+ };
15
+
16
+ /* 文字档的语义色,已经按主题切好;不要直接用填充色 */
17
+ const toneClass: Record<TermTone, string> = {
18
+ accent: "text-accent-ink",
19
+ info: "text-info",
20
+ success: "text-success",
21
+ warning: "text-warning",
22
+ danger: "text-danger",
23
+ };
24
+
25
+ /**
26
+ * 正文里着色的机制词(技能、属性、数值)。着色的同时加粗,不只靠颜色。
27
+ * 用在页面表面上;反转块上这些颜色不够亮,不要用。
28
+ */
29
+ export function Term({
30
+ tone = "accent",
31
+ icon,
32
+ className,
33
+ children,
34
+ ...props
35
+ }: TermProps) {
36
+ return (
37
+ <strong
38
+ {...props}
39
+ data-tone={tone}
40
+ className={cn("font-bold", toneClass[tone], className)}
41
+ >
42
+ {icon && (
43
+ <span
44
+ aria-hidden="true"
45
+ className="mr-[0.2em] inline-flex align-[-0.125em] [&_svg]:size-[1em]"
46
+ >
47
+ {icon}
48
+ </span>
49
+ )}
50
+ {children}
51
+ </strong>
52
+ );
53
+ }
@@ -0,0 +1 @@
1
+ export { Term, type TermProps, type TermTone } from "./Term";
@@ -0,0 +1,99 @@
1
+ import { useState, type ChangeEvent, type ComponentProps } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { useFieldControl } from "../field/Field";
4
+ import {
5
+ controlBox,
6
+ controlElement,
7
+ type ControlVariant,
8
+ } from "../input/control-box";
9
+
10
+ export type TextareaProps = ComponentProps<"textarea"> & {
11
+ /** `sunken` 凹陷底 + 底边线(默认),或 `outline` 四边描边 */
12
+ variant?: ControlVariant;
13
+ /** 错误态:底边线变红。放在 `Field` 里时跟随它的 `error` */
14
+ invalid?: boolean;
15
+ /** 在右下角显示字数;有 `maxLength` 时显示成 `12 / 200` */
16
+ showCount?: boolean;
17
+ };
18
+
19
+ /**
20
+ * 多行文本,最少三行。`className` 给外框,其余属性与 `ref` 给里面的 `<textarea>`。
21
+ */
22
+ export function Textarea({
23
+ variant = "sunken",
24
+ invalid: invalidProp,
25
+ showCount = false,
26
+ id: idProp,
27
+ disabled: disabledProp,
28
+ required: requiredProp,
29
+ readOnly = false,
30
+ "aria-describedby": describedByProp,
31
+ rows = 3,
32
+ maxLength,
33
+ value,
34
+ defaultValue,
35
+ onChange,
36
+ className,
37
+ ...props
38
+ }: TextareaProps) {
39
+ const { id, disabled, required, invalid, ...field } = useFieldControl({
40
+ id: idProp,
41
+ disabled: disabledProp,
42
+ required: requiredProp,
43
+ invalid: invalidProp,
44
+ "aria-describedby": describedByProp,
45
+ });
46
+
47
+ // 非受控时自己记字数;受控时直接量 value
48
+ const [innerLength, setInnerLength] = useState(
49
+ () => String(defaultValue ?? "").length,
50
+ );
51
+ const length = value !== undefined ? String(value).length : innerLength;
52
+
53
+ const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
54
+ setInnerLength(event.target.value.length);
55
+ onChange?.(event);
56
+ };
57
+
58
+ return (
59
+ <div
60
+ data-variant={variant}
61
+ data-invalid={invalid ? "" : undefined}
62
+ className={cn(
63
+ controlBox({ variant, invalid, disabled, readOnly }),
64
+ "flex-col text-base",
65
+ className,
66
+ )}
67
+ >
68
+ <textarea
69
+ {...props}
70
+ id={id}
71
+ rows={rows}
72
+ maxLength={maxLength}
73
+ value={value}
74
+ defaultValue={defaultValue}
75
+ onChange={handleChange}
76
+ disabled={disabled}
77
+ required={required}
78
+ readOnly={readOnly}
79
+ aria-invalid={invalid || undefined}
80
+ aria-describedby={field["aria-describedby"]}
81
+ className={cn(
82
+ controlElement,
83
+ "block min-h-[calc(3lh+1rem)] w-full resize-y px-3 py-2",
84
+ )}
85
+ />
86
+ {showCount && (
87
+ <span
88
+ data-count=""
89
+ className={cn(
90
+ "pointer-events-none self-end px-3 pb-1.5 font-tech text-xs tabular-nums",
91
+ disabled ? "text-ink-disabled" : "text-ink-secondary",
92
+ )}
93
+ >
94
+ {maxLength !== undefined ? `${length} / ${maxLength}` : length}
95
+ </span>
96
+ )}
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1 @@
1
+ export { Textarea, type TextareaProps } from "./Textarea";