@endfield-ui/react 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +27 -0
  3. package/README.md +768 -2
  4. package/dist/index.d.ts +3243 -0
  5. package/dist/index.js +8182 -0
  6. package/dist/styles.css +2 -0
  7. package/package.json +83 -3
  8. package/src/components/README.md +172 -0
  9. package/src/components/accordion/Accordion.tsx +168 -0
  10. package/src/components/accordion/index.ts +8 -0
  11. package/src/components/alert/Alert.tsx +106 -0
  12. package/src/components/alert/index.ts +1 -0
  13. package/src/components/avatar/Avatar.tsx +89 -0
  14. package/src/components/avatar/AvatarSwitcher.tsx +261 -0
  15. package/src/components/avatar/index.ts +8 -0
  16. package/src/components/back-to-top/BackToTop.tsx +124 -0
  17. package/src/components/back-to-top/index.ts +5 -0
  18. package/src/components/badge/Badge.tsx +75 -0
  19. package/src/components/badge/index.ts +1 -0
  20. package/src/components/bracket-title/BracketTitle.tsx +40 -0
  21. package/src/components/bracket-title/index.ts +1 -0
  22. package/src/components/breadcrumb/Breadcrumb.tsx +161 -0
  23. package/src/components/breadcrumb/index.ts +6 -0
  24. package/src/components/button/Button.tsx +357 -0
  25. package/src/components/button/ButtonGroup.tsx +55 -0
  26. package/src/components/button/index.ts +13 -0
  27. package/src/components/calendar/Calendar.tsx +443 -0
  28. package/src/components/calendar/index.ts +7 -0
  29. package/src/components/carousel/Carousel.tsx +292 -0
  30. package/src/components/carousel/index.ts +7 -0
  31. package/src/components/checkbox/Checkbox.tsx +94 -0
  32. package/src/components/checkbox/choice-style.ts +30 -0
  33. package/src/components/checkbox/index.ts +1 -0
  34. package/src/components/chip/FilterChip.tsx +68 -0
  35. package/src/components/chip/ResourceChip.tsx +63 -0
  36. package/src/components/chip/capsule-style.ts +22 -0
  37. package/src/components/chip/index.ts +6 -0
  38. package/src/components/combobox/Combobox.tsx +450 -0
  39. package/src/components/combobox/index.ts +10 -0
  40. package/src/components/completion-banner/CompletionBanner.tsx +72 -0
  41. package/src/components/completion-banner/index.ts +4 -0
  42. package/src/components/context-menu/ContextMenu.tsx +70 -0
  43. package/src/components/context-menu/index.ts +5 -0
  44. package/src/components/corner-brackets/CornerBrackets.tsx +43 -0
  45. package/src/components/corner-brackets/index.ts +5 -0
  46. package/src/components/countdown/Countdown.tsx +145 -0
  47. package/src/components/countdown/index.ts +6 -0
  48. package/src/components/dash-indicator/DashIndicator.tsx +44 -0
  49. package/src/components/dash-indicator/index.ts +1 -0
  50. package/src/components/data-row/DataRow.tsx +282 -0
  51. package/src/components/data-row/index.ts +8 -0
  52. package/src/components/date-picker/DateField.tsx +159 -0
  53. package/src/components/date-picker/DatePicker.tsx +182 -0
  54. package/src/components/date-picker/DateRangePicker.tsx +203 -0
  55. package/src/components/date-picker/index.ts +7 -0
  56. package/src/components/dialog/Dialog.tsx +203 -0
  57. package/src/components/dialog/index.ts +7 -0
  58. package/src/components/dialog/initial-focus.ts +19 -0
  59. package/src/components/dialog/overlay-style.ts +28 -0
  60. package/src/components/drawer/Drawer.tsx +213 -0
  61. package/src/components/drawer/index.ts +6 -0
  62. package/src/components/dropdown-menu/DropdownMenu.tsx +427 -0
  63. package/src/components/dropdown-menu/MenuCheck.tsx +44 -0
  64. package/src/components/dropdown-menu/index.ts +22 -0
  65. package/src/components/dropdown-menu/menu-style.ts +47 -0
  66. package/src/components/empty-state/EmptyState.tsx +79 -0
  67. package/src/components/empty-state/index.ts +1 -0
  68. package/src/components/field/Field.tsx +203 -0
  69. package/src/components/field/index.ts +6 -0
  70. package/src/components/file-upload/FileItem.tsx +201 -0
  71. package/src/components/file-upload/FileUpload.tsx +425 -0
  72. package/src/components/file-upload/index.ts +9 -0
  73. package/src/components/flyout-bar/FlyoutBar.tsx +158 -0
  74. package/src/components/flyout-bar/index.ts +7 -0
  75. package/src/components/ghost-text/GhostText.tsx +47 -0
  76. package/src/components/ghost-text/index.ts +5 -0
  77. package/src/components/hatch/Hatch.tsx +35 -0
  78. package/src/components/hatch/index.ts +1 -0
  79. package/src/components/hazard-stripe/HazardStripe.tsx +35 -0
  80. package/src/components/hazard-stripe/index.ts +5 -0
  81. package/src/components/icon-button/IconButton.tsx +95 -0
  82. package/src/components/icon-button/index.ts +6 -0
  83. package/src/components/input/Input.tsx +109 -0
  84. package/src/components/input/control-box.ts +71 -0
  85. package/src/components/input/index.ts +6 -0
  86. package/src/components/input/value-chip.ts +34 -0
  87. package/src/components/item-slot/ItemGrid.tsx +207 -0
  88. package/src/components/item-slot/ItemSlot.tsx +283 -0
  89. package/src/components/item-slot/index.ts +7 -0
  90. package/src/components/kbd/Kbd.tsx +33 -0
  91. package/src/components/kbd/index.ts +1 -0
  92. package/src/components/link-element/LinkElement.tsx +25 -0
  93. package/src/components/list/List.tsx +229 -0
  94. package/src/components/list/band-style.ts +18 -0
  95. package/src/components/list/index.ts +8 -0
  96. package/src/components/loader/Loader.tsx +154 -0
  97. package/src/components/loader/index.ts +1 -0
  98. package/src/components/marquee/Marquee.tsx +96 -0
  99. package/src/components/marquee/index.ts +1 -0
  100. package/src/components/media-card/MediaCard.tsx +139 -0
  101. package/src/components/media-card/index.ts +5 -0
  102. package/src/components/nav-action/NavAction.tsx +142 -0
  103. package/src/components/nav-action/index.ts +5 -0
  104. package/src/components/nav-menu/NavMenu.tsx +288 -0
  105. package/src/components/nav-menu/index.ts +6 -0
  106. package/src/components/navigator/Navigator.tsx +136 -0
  107. package/src/components/navigator/index.ts +5 -0
  108. package/src/components/pagination/Pagination.tsx +181 -0
  109. package/src/components/pagination/index.ts +5 -0
  110. package/src/components/panel/Panel.tsx +114 -0
  111. package/src/components/panel/index.ts +13 -0
  112. package/src/components/play-button/PlayButton.tsx +92 -0
  113. package/src/components/play-button/index.ts +7 -0
  114. package/src/components/popover/Popover.tsx +105 -0
  115. package/src/components/popover/index.ts +8 -0
  116. package/src/components/progress/Progress.tsx +133 -0
  117. package/src/components/progress/ProgressRing.tsx +123 -0
  118. package/src/components/progress/index.ts +2 -0
  119. package/src/components/radio/Radio.tsx +182 -0
  120. package/src/components/radio/index.ts +7 -0
  121. package/src/components/rec-indicator/RecIndicator.tsx +47 -0
  122. package/src/components/rec-indicator/index.ts +1 -0
  123. package/src/components/registration-strip/RegistrationStrip.tsx +51 -0
  124. package/src/components/registration-strip/index.ts +5 -0
  125. package/src/components/schedule/Schedule.tsx +457 -0
  126. package/src/components/schedule/index.ts +9 -0
  127. package/src/components/scroll-hint/ScrollHint.tsx +78 -0
  128. package/src/components/scroll-hint/index.ts +5 -0
  129. package/src/components/section-title/SectionTitle.tsx +202 -0
  130. package/src/components/section-title/index.ts +5 -0
  131. package/src/components/segmented-control/SegmentedControl.tsx +220 -0
  132. package/src/components/segmented-control/index.ts +8 -0
  133. package/src/components/select/Select.tsx +390 -0
  134. package/src/components/select/index.ts +15 -0
  135. package/src/components/side-rail/SideRail.tsx +317 -0
  136. package/src/components/side-rail/SideRailSub.tsx +298 -0
  137. package/src/components/side-rail/index.ts +14 -0
  138. package/src/components/skeleton/Skeleton.tsx +70 -0
  139. package/src/components/skeleton/index.ts +1 -0
  140. package/src/components/slider/Slider.tsx +219 -0
  141. package/src/components/slider/index.ts +8 -0
  142. package/src/components/sparkline/Sparkline.tsx +122 -0
  143. package/src/components/sparkline/index.ts +6 -0
  144. package/src/components/spinner/Spinner.tsx +64 -0
  145. package/src/components/spinner/index.ts +1 -0
  146. package/src/components/stat/Stat.tsx +79 -0
  147. package/src/components/stat/index.ts +1 -0
  148. package/src/components/stepper/Stepper.tsx +316 -0
  149. package/src/components/stepper/index.ts +1 -0
  150. package/src/components/steps/Steps.tsx +302 -0
  151. package/src/components/steps/index.ts +10 -0
  152. package/src/components/switch/Switch.tsx +120 -0
  153. package/src/components/switch/index.ts +1 -0
  154. package/src/components/table/Table.tsx +559 -0
  155. package/src/components/table/index.ts +20 -0
  156. package/src/components/tabs/Tabs.tsx +313 -0
  157. package/src/components/tabs/index.ts +12 -0
  158. package/src/components/tag/Tag.tsx +66 -0
  159. package/src/components/tag/TagPair.tsx +34 -0
  160. package/src/components/tag/index.ts +2 -0
  161. package/src/components/tag-input/TagInput.tsx +501 -0
  162. package/src/components/tag-input/index.ts +8 -0
  163. package/src/components/term/Term.tsx +53 -0
  164. package/src/components/term/index.ts +1 -0
  165. package/src/components/textarea/Textarea.tsx +99 -0
  166. package/src/components/textarea/index.ts +1 -0
  167. package/src/components/texture/Texture.tsx +45 -0
  168. package/src/components/texture/index.ts +1 -0
  169. package/src/components/tick-ring/TickRing.tsx +77 -0
  170. package/src/components/tick-ring/index.ts +1 -0
  171. package/src/components/timeline/Timeline.tsx +84 -0
  172. package/src/components/timeline/index.ts +7 -0
  173. package/src/components/toast/Toast.tsx +257 -0
  174. package/src/components/toast/index.ts +10 -0
  175. package/src/components/toc/Toc.tsx +262 -0
  176. package/src/components/toc/index.ts +7 -0
  177. package/src/components/toolbar/Toolbar.tsx +389 -0
  178. package/src/components/toolbar/index.ts +17 -0
  179. package/src/components/tooltip/Tooltip.tsx +175 -0
  180. package/src/components/tooltip/index.ts +8 -0
  181. package/src/components/top-bar/TopBar.tsx +47 -0
  182. package/src/components/top-bar/index.ts +1 -0
  183. package/src/components/viewfinder/Viewfinder.tsx +97 -0
  184. package/src/components/viewfinder/index.ts +6 -0
  185. package/src/hooks/README.md +14 -0
  186. package/src/hooks/index.ts +4 -0
  187. package/src/hooks/useControllableState.ts +30 -0
  188. package/src/hooks/useInView.ts +44 -0
  189. package/src/hooks/useOverflowing.ts +28 -0
  190. package/src/hooks/usePortalScope.ts +68 -0
  191. package/src/hooks/useScrollPosition.ts +57 -0
  192. package/src/hooks/useTheme.ts +102 -0
  193. package/src/icons/ArrowCorner.tsx +7 -0
  194. package/src/icons/ArrowLeft.tsx +6 -0
  195. package/src/icons/ArrowRight.tsx +6 -0
  196. package/src/icons/CalendarIcon.tsx +10 -0
  197. package/src/icons/Check.tsx +4 -0
  198. package/src/icons/ChevronDown.tsx +3 -0
  199. package/src/icons/ChevronLeft.tsx +6 -0
  200. package/src/icons/ChevronRight.tsx +6 -0
  201. package/src/icons/Close.tsx +3 -0
  202. package/src/icons/Crosshair.tsx +7 -0
  203. package/src/icons/Lock.tsx +6 -0
  204. package/src/icons/Menu.tsx +4 -0
  205. package/src/icons/Minus.tsx +4 -0
  206. package/src/icons/Plus.tsx +3 -0
  207. package/src/icons/README.md +31 -0
  208. package/src/icons/StatusDanger.tsx +7 -0
  209. package/src/icons/StatusInfo.tsx +7 -0
  210. package/src/icons/StatusSuccess.tsx +10 -0
  211. package/src/icons/StatusWarning.tsx +7 -0
  212. package/src/icons/TriangleRight.tsx +7 -0
  213. package/src/icons/Upload.tsx +7 -0
  214. package/src/icons/createIcon.tsx +34 -0
  215. package/src/icons/index.ts +21 -0
  216. package/src/index.ts +82 -0
  217. package/src/lib/README.md +17 -0
  218. package/src/lib/cn.ts +34 -0
  219. package/src/lib/date.ts +125 -0
  220. package/src/lib/decor.ts +6 -0
  221. package/src/lib/defined.ts +9 -0
  222. package/src/lib/file.ts +61 -0
  223. package/src/lib/focus-ring.ts +11 -0
  224. package/src/lib/merge-refs.ts +11 -0
  225. package/src/styles/README.md +70 -0
  226. package/src/styles/index.css +25 -0
  227. package/src/styles/theme.css +568 -0
  228. package/src/styles/utilities.css +241 -0
@@ -0,0 +1,47 @@
1
+ import { createContext } from "react";
2
+ import { focusRingInset } from "../../lib/focus-ring";
3
+
4
+ /*
5
+ * 下拉菜单与下拉选择共用的面板和选项。
6
+ * - `plain` 跟随主题的面板,当前项是浅灰底 + 左缘墨色条;
7
+ * - `strong` 官网语言菜单的样子:固定的深色面板,当前项整行黄底墨字。
8
+ * 面板自己是一个 `data-theme="dark"` 的局部主题,所以下面的类不用分两套写。
9
+ */
10
+ export type MenuVariant = "plain" | "strong";
11
+
12
+ /** 选项要知道自己在哪种面板里,才知道"当前项"怎么画 */
13
+ export const MenuVariantContext = createContext<MenuVariant>("plain");
14
+
15
+ /** 面板:直角、1px 线、环境阴影;顶部一条半宽的行动色细条,展开时从 0 伸到一半宽 */
16
+ export const menuPanel = [
17
+ "relative min-w-40 border border-line bg-surface-raised py-1 text-ink shadow-sm outline-none",
18
+ "origin-(--transform-origin) transition-opacity duration-(--duration-fast) ease-standard",
19
+ "data-ending-style:opacity-0 data-starting-style:opacity-0",
20
+ "before:pointer-events-none before:absolute before:-top-px before:-left-px before:h-[3px] before:w-1/2 before:origin-left before:bg-action before:content-['']",
21
+ "before:transition-transform before:duration-(--duration-base) before:ease-exit data-starting-style:before:scale-x-0",
22
+ "forced-colors:before:hidden",
23
+ ].join(" ");
24
+
25
+ /**
26
+ * 选项:键盘或指针经过时提亮一档;键盘聚焦另有一圈画在里面的焦点环。
27
+ * 这里不能写 `outline-none`:它把轮廓的线型定成 none,后面的焦点环也跟着画不出来
28
+ */
29
+ export const menuItem = [
30
+ "relative flex min-h-9 cursor-default items-center gap-2 px-3 py-1.5 text-sm select-none",
31
+ "data-disabled:text-ink-disabled",
32
+ focusRingInset,
33
+ ].join(" ");
34
+
35
+ /** 不是当前项的选项才有悬停底:当前项的底不能被悬停盖掉 */
36
+ export const menuItemHover = "data-highlighted:bg-ink/5";
37
+
38
+ /** 当前项(下拉选择的已选项、单选菜单项的选中项) */
39
+ export const menuItemCurrent: Record<MenuVariant, string> = {
40
+ plain:
41
+ "bg-surface-muted font-medium before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-ink before:content-['']",
42
+ strong: "bg-action font-medium text-on-action",
43
+ };
44
+
45
+ export const menuGroupLabel = "px-3 pt-2 pb-1 text-xs text-ink-tertiary";
46
+
47
+ export const menuSeparator = "my-1 h-px bg-line";
@@ -0,0 +1,79 @@
1
+ import type { ComponentProps, ElementType, ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export type EmptyStateProps = Omit<ComponentProps<"div">, "title"> & {
5
+ /** 一句话说明现状:"暂无记录" */
6
+ title: ReactNode;
7
+ /** 一句话说明原因或下一步 */
8
+ description?: ReactNode;
9
+ /** 可选的一个行动,通常是一个 `control` 按钮 */
10
+ action?: ReactNode;
11
+ /** 图形。默认是取景角 + 准星;传 `null` 去掉 */
12
+ icon?: ReactNode;
13
+ /** 虚线描边,默认有 */
14
+ bordered?: boolean;
15
+ /** 标题层级,默认 3(`<h3>`) */
16
+ level?: 2 | 3 | 4 | 5 | 6;
17
+ };
18
+
19
+ /** 取景角 + 准星:游戏内图鉴用它表示"未获得",空状态沿用这个符号 */
20
+ function Viewfinder() {
21
+ return (
22
+ <svg
23
+ width={56}
24
+ height={56}
25
+ viewBox="0 0 48 48"
26
+ fill="none"
27
+ stroke="currentColor"
28
+ strokeWidth={1.5}
29
+ strokeLinecap="butt"
30
+ strokeLinejoin="miter"
31
+ aria-hidden="true"
32
+ focusable="false"
33
+ >
34
+ <path d="M4 14V4h10M34 4h10v10M44 34v10H34M14 44H4V34" />
35
+ <path d="M24 18v12M18 24h12" />
36
+ </svg>
37
+ );
38
+ }
39
+
40
+ /** 没有内容时的占位。说清楚为什么为空、接下来能做什么;不要用大幅插画填满。 */
41
+ export function EmptyState({
42
+ title,
43
+ description,
44
+ action,
45
+ icon,
46
+ bordered = true,
47
+ level = 3,
48
+ className,
49
+ children,
50
+ ...props
51
+ }: EmptyStateProps) {
52
+ const Heading: ElementType = `h${level}`;
53
+ const graphic = icon === undefined ? <Viewfinder /> : icon;
54
+
55
+ return (
56
+ <div
57
+ {...props}
58
+ className={cn(
59
+ "flex flex-col items-center px-6 py-10 text-center text-ink",
60
+ bordered && "border border-dashed border-line-strong",
61
+ className,
62
+ )}
63
+ >
64
+ {graphic !== null && (
65
+ <div aria-hidden="true" className="mb-4 text-ink-tertiary">
66
+ {graphic}
67
+ </div>
68
+ )}
69
+ <Heading className="text-base font-medium">{title}</Heading>
70
+ {description && (
71
+ <p className="mt-1 max-w-[40ch] text-sm text-ink-secondary">
72
+ {description}
73
+ </p>
74
+ )}
75
+ {children}
76
+ {action && <div className="mt-5">{action}</div>}
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1 @@
1
+ export { EmptyState, type EmptyStateProps } from "./EmptyState";
@@ -0,0 +1,203 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useId,
5
+ useMemo,
6
+ type HTMLAttributes,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { StatusDanger } from "../../icons/StatusDanger";
10
+ import { cn } from "../../lib/cn";
11
+
12
+ type FieldContextValue = {
13
+ /** 单个控件的 id;`group` 字段里有多个控件,不提供 */
14
+ controlId: string | undefined;
15
+ labelId: string;
16
+ describedBy: string | undefined;
17
+ invalid: boolean;
18
+ disabled: boolean;
19
+ required: boolean;
20
+ group: boolean;
21
+ };
22
+
23
+ const FieldContext = createContext<FieldContextValue | null>(null);
24
+
25
+ /** 给成组的控件(单选组)读取所在字段的状态。不在 `Field` 里时返回 `null`。 */
26
+ export function useFieldContext(): FieldContextValue | null {
27
+ return useContext(FieldContext);
28
+ }
29
+
30
+ type ControlProps = {
31
+ id?: string;
32
+ disabled?: boolean;
33
+ required?: boolean;
34
+ /** 错误态。不传时跟随所在的 `Field` */
35
+ invalid?: boolean;
36
+ "aria-describedby"?: string;
37
+ };
38
+
39
+ /**
40
+ * 把所在 `Field` 的 id、说明文字、错误态、禁用、必填并进控件自己的属性。
41
+ * 控件上显式传的值优先。自定义控件用它接入 `Field`。
42
+ */
43
+ export function useFieldControl({
44
+ id,
45
+ disabled,
46
+ required,
47
+ invalid,
48
+ "aria-describedby": describedBy,
49
+ }: ControlProps) {
50
+ const field = useContext(FieldContext);
51
+ const mergedDescribedBy =
52
+ [describedBy, field?.describedBy].filter(Boolean).join(" ") || undefined;
53
+
54
+ return {
55
+ id: id ?? field?.controlId,
56
+ disabled: disabled ?? field?.disabled ?? false,
57
+ required: required ?? field?.required ?? false,
58
+ invalid: invalid ?? field?.invalid ?? false,
59
+ "aria-describedby": mergedDescribedBy,
60
+ };
61
+ }
62
+
63
+ export type FieldProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
64
+ /** 标签,在控件上方。不要用占位文字代替它 */
65
+ label: ReactNode;
66
+ /** 帮助文字,在控件下方 */
67
+ help?: ReactNode;
68
+ /** 错误说明:写"哪里不对、怎么改"。有值即为错误态 */
69
+ error?: ReactNode;
70
+ /** 必填:标签后加一个小菱形,并交给里面的控件 */
71
+ required?: boolean;
72
+ /** 禁用:标签变浅,并交给里面的控件 */
73
+ disabled?: boolean;
74
+ /**
75
+ * 里面是一组控件(几个复选、一个单选组)而不是单个输入框时打开:
76
+ * 渲染成 `<fieldset>` + `<legend>`。
77
+ */
78
+ group?: boolean;
79
+ /** 单个控件的 id,缺省自动生成 */
80
+ controlId?: string;
81
+ children: ReactNode;
82
+ };
83
+
84
+ /** 表单字段:标签在上、控件在下,下面是帮助与错误文字,三者自动关联。 */
85
+ export function Field({
86
+ label,
87
+ help,
88
+ error,
89
+ required = false,
90
+ disabled = false,
91
+ group = false,
92
+ controlId,
93
+ className,
94
+ children,
95
+ ...props
96
+ }: FieldProps) {
97
+ const baseId = useId();
98
+ const id = controlId ?? `${baseId}-control`;
99
+ const labelId = `${baseId}-label`;
100
+ const helpId = `${baseId}-help`;
101
+ const errorId = `${baseId}-error`;
102
+
103
+ const hasError = error !== undefined && error !== null && error !== false;
104
+ const hasHelp = help !== undefined && help !== null && help !== false;
105
+ const describedBy =
106
+ [hasError && errorId, hasHelp && helpId].filter(Boolean).join(" ") ||
107
+ undefined;
108
+
109
+ const context = useMemo<FieldContextValue>(
110
+ () => ({
111
+ controlId: group ? undefined : id,
112
+ labelId,
113
+ describedBy,
114
+ invalid: hasError,
115
+ disabled,
116
+ required,
117
+ group,
118
+ }),
119
+ [group, id, labelId, describedBy, hasError, disabled, required],
120
+ );
121
+
122
+ const labelClass = cn(
123
+ "block text-sm font-medium",
124
+ disabled ? "text-ink-disabled" : "text-ink",
125
+ );
126
+
127
+ const labelContent = (
128
+ <>
129
+ {label}
130
+ {required && (
131
+ // 行动色在亮色表面上看不见,必填记号用强调色的文字档
132
+ <span
133
+ aria-hidden="true"
134
+ data-required=""
135
+ className={cn(
136
+ "ml-1.5 inline-block size-1.5 rotate-45 align-middle",
137
+ disabled ? "bg-ink-disabled" : "bg-accent-ink",
138
+ )}
139
+ />
140
+ )}
141
+ </>
142
+ );
143
+
144
+ const messages = (
145
+ <>
146
+ {hasError && (
147
+ <p
148
+ id={errorId}
149
+ className="mt-1.5 flex items-start gap-1.5 text-sm text-danger"
150
+ >
151
+ <StatusDanger size={16} className="mt-0.5 shrink-0" />
152
+ <span className="min-w-0 wrap-anywhere">{error}</span>
153
+ </p>
154
+ )}
155
+ {hasHelp && (
156
+ <p
157
+ id={helpId}
158
+ className={cn(
159
+ "mt-1.5 text-sm",
160
+ disabled ? "text-ink-disabled" : "text-ink-secondary",
161
+ )}
162
+ >
163
+ {help}
164
+ </p>
165
+ )}
166
+ </>
167
+ );
168
+
169
+ if (group) {
170
+ return (
171
+ <fieldset
172
+ {...props}
173
+ disabled={disabled}
174
+ aria-describedby={describedBy}
175
+ data-invalid={hasError ? "" : undefined}
176
+ className={cn("min-w-0", className)}
177
+ >
178
+ <legend id={labelId} className={cn(labelClass, "mb-1.5")}>
179
+ {labelContent}
180
+ </legend>
181
+ {/* 组里的控件默认竖排;要横排就自己包一层,作为这里唯一的子元素 */}
182
+ <div className="flex flex-col items-start">
183
+ <FieldContext value={context}>{children}</FieldContext>
184
+ </div>
185
+ {messages}
186
+ </fieldset>
187
+ );
188
+ }
189
+
190
+ return (
191
+ <div
192
+ {...props}
193
+ data-invalid={hasError ? "" : undefined}
194
+ className={cn("min-w-0", className)}
195
+ >
196
+ <label id={labelId} htmlFor={id} className={cn(labelClass, "mb-1.5")}>
197
+ {labelContent}
198
+ </label>
199
+ <FieldContext value={context}>{children}</FieldContext>
200
+ {messages}
201
+ </div>
202
+ );
203
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ Field,
3
+ useFieldContext,
4
+ useFieldControl,
5
+ type FieldProps,
6
+ } from "./Field";
@@ -0,0 +1,201 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ import { Close } from "../../icons/Close";
3
+ import { StatusDanger } from "../../icons/StatusDanger";
4
+ import { StatusSuccess } from "../../icons/StatusSuccess";
5
+ import { cn } from "../../lib/cn";
6
+ import { fileExtension, formatFileSize } from "../../lib/file";
7
+ import { focusRing, focusRingInset } from "../../lib/focus-ring";
8
+ import { LinkElement, type LinkRender } from "../link-element/LinkElement";
9
+ import { Progress } from "../progress/Progress";
10
+ import { Tag } from "../tag/Tag";
11
+
12
+ export type FileItemStatus = "uploading" | "done" | "error";
13
+
14
+ type OwnProps = {
15
+ /** 文件名。前面的扩展名标签是从它里面取的 */
16
+ name: string;
17
+ /** 大小,字节 */
18
+ size?: number;
19
+ /**
20
+ * 传到哪一步了,由使用方给:
21
+ * - 不传:只是列出来;
22
+ * - `uploading` 行底一条进度条,大小的位置换成百分比;
23
+ * - `done` 多一个成功图标;
24
+ * - `error` 文件名下面一行错误说明。
25
+ */
26
+ status?: FileItemStatus;
27
+ /** `uploading` 时的进度,0 – 100。不传就是不确定进度 */
28
+ progress?: number;
29
+ /** `error` 时的说明:写"怎么了、怎么办" */
30
+ error?: ReactNode;
31
+ /** 传了右端就有移除钮 */
32
+ onRemove?: () => void;
33
+ /** 传了、且是 `error` 时,多一个"重试" */
34
+ onRetry?: () => void;
35
+ /** 移除钮的可访问名称,默认"移除某某" */
36
+ removeLabel?: string;
37
+ /** "重试"两个字 */
38
+ retryLabel?: ReactNode;
39
+ /** `done` 时读屏多听到的一句,默认"已上传" */
40
+ doneLabel?: string;
41
+ /** 进度条的可访问名称,默认"某某的上传进度" */
42
+ progressLabel?: string;
43
+ /** 传了文件名就是链接:已有的附件可以点开 */
44
+ href?: string;
45
+ target?: string;
46
+ rel?: string;
47
+ /** 用这个元素代替 `<a>`(路由库的链接组件)。传了就按链接处理 */
48
+ render?: LinkRender;
49
+ disabled?: boolean;
50
+ };
51
+
52
+ export type FileItemProps = OwnProps &
53
+ Omit<ComponentProps<"div">, keyof OwnProps | "children">;
54
+
55
+ /**
56
+ * 一个文件一行:扩展名、文件名、大小、状态、移除钮。
57
+ * `FileUpload` 里每个文件默认渲染一行;也可以单独拿去列"已有的附件"。
58
+ */
59
+ export function FileItem({
60
+ name,
61
+ size,
62
+ status,
63
+ progress,
64
+ error,
65
+ onRemove,
66
+ onRetry,
67
+ removeLabel = `移除${name}`,
68
+ retryLabel = "重试",
69
+ doneLabel = "已上传",
70
+ progressLabel = `${name}的上传进度`,
71
+ href,
72
+ target,
73
+ rel,
74
+ render,
75
+ disabled = false,
76
+ className,
77
+ ...props
78
+ }: FileItemProps) {
79
+ const uploading = status === "uploading";
80
+ const failed = status === "error";
81
+ const isLink = href !== undefined || render !== undefined;
82
+
83
+ let meta: string | null = null;
84
+ if (uploading && progress !== undefined) {
85
+ meta = `${Math.round(Math.min(Math.max(progress, 0), 100))}%`;
86
+ } else if (!uploading && size !== undefined) {
87
+ meta = formatFileSize(size);
88
+ }
89
+
90
+ return (
91
+ <div
92
+ {...props}
93
+ data-status={status}
94
+ className={cn(
95
+ "relative flex min-h-10 items-center gap-3 bg-surface-muted pl-2 text-sm",
96
+ disabled ? "text-ink-disabled" : "text-ink",
97
+ !onRemove && "pr-3",
98
+ className,
99
+ )}
100
+ >
101
+ <Tag
102
+ // 禁用的那一行,扩展名也跟着降下去
103
+ variant={disabled ? "muted" : "inverse"}
104
+ size="sm"
105
+ aria-hidden="true"
106
+ data-extension=""
107
+ className={cn(
108
+ "min-w-10 justify-center font-tech font-bold",
109
+ disabled && "text-ink-disabled",
110
+ )}
111
+ >
112
+ {fileExtension(name) || "FILE"}
113
+ </Tag>
114
+
115
+ <span className="flex min-w-0 flex-1 flex-col py-1.5">
116
+ {isLink ? (
117
+ <LinkElement
118
+ render={render}
119
+ href={href}
120
+ target={target}
121
+ rel={rel}
122
+ title={name}
123
+ className={cn("truncate underline underline-offset-4", focusRing)}
124
+ >
125
+ {name}
126
+ </LinkElement>
127
+ ) : (
128
+ <span title={name} className="truncate">
129
+ {name}
130
+ </span>
131
+ )}
132
+ {failed && error && (
133
+ <span className="flex items-start gap-1 text-xs text-danger">
134
+ <StatusDanger size={12} className="mt-0.5 shrink-0" />
135
+ <span className="min-w-0 wrap-anywhere">{error}</span>
136
+ </span>
137
+ )}
138
+ </span>
139
+
140
+ {meta !== null && (
141
+ <span
142
+ data-meta=""
143
+ className={cn(
144
+ "shrink-0 font-tech text-xs tabular-nums",
145
+ disabled ? "text-ink-disabled" : "text-ink-secondary",
146
+ )}
147
+ >
148
+ {meta}
149
+ </span>
150
+ )}
151
+ {status === "done" && (
152
+ <>
153
+ <StatusSuccess size={16} className="shrink-0 text-success" />
154
+ <span className="sr-only">{doneLabel}</span>
155
+ </>
156
+ )}
157
+ {failed && onRetry && (
158
+ <button
159
+ type="button"
160
+ disabled={disabled}
161
+ onClick={onRetry}
162
+ className={cn(
163
+ "shrink-0 font-medium underline underline-offset-4",
164
+ focusRing,
165
+ )}
166
+ >
167
+ {retryLabel}
168
+ </button>
169
+ )}
170
+ {onRemove && (
171
+ <button
172
+ type="button"
173
+ data-file-remove=""
174
+ aria-label={removeLabel}
175
+ disabled={disabled}
176
+ onClick={onRemove}
177
+ className={cn(
178
+ "flex size-8 shrink-0 items-center justify-center self-center",
179
+ "transition-colors duration-(--duration-fast) ease-standard",
180
+ disabled
181
+ ? "cursor-not-allowed text-ink-disabled"
182
+ : "text-ink-secondary hover:bg-line hover:text-ink",
183
+ // 这一行和上下两行只隔 4px,环画在里面
184
+ focusRingInset,
185
+ )}
186
+ >
187
+ <Close size={14} />
188
+ </button>
189
+ )}
190
+
191
+ {uploading && (
192
+ <Progress
193
+ size="sm"
194
+ value={progress}
195
+ aria-label={progressLabel}
196
+ className="absolute inset-x-0 bottom-0"
197
+ />
198
+ )}
199
+ </div>
200
+ );
201
+ }