@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,145 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ type ComponentProps,
6
+ type ReactNode,
7
+ } from "react";
8
+ import { cn } from "../../lib/cn";
9
+
10
+ export type CountdownSize = "sm" | "md";
11
+ export type CountdownState = "ample" | "urgent" | "expired";
12
+
13
+ export type CountdownProps = Omit<ComponentProps<"span">, "children"> & {
14
+ /** 截止时间:`Date`、时间戳或能被 `new Date()` 解析的字符串 */
15
+ to: Date | number | string;
16
+ /** 剩余时间不多于这个毫秒数时进入紧迫态,默认 24 小时 */
17
+ urgentWithin?: number;
18
+ /** 到期后显示的文字 */
19
+ expiredLabel?: ReactNode;
20
+ /** 到期的那一刻调用一次;挂载时就已过期则不调用 */
21
+ onExpire?: () => void;
22
+ /** 超过一天时"天"的写法,如 `3天 04:12:09` */
23
+ dayUnit?: string;
24
+ /** 给读屏的静态截止时间。默认是"截止 + 本地格式的日期时间" */
25
+ deadlineLabel?: ReactNode;
26
+ size?: CountdownSize;
27
+ };
28
+
29
+ const DAY = 86_400_000;
30
+
31
+ const sizeClass: Record<CountdownSize, string> = {
32
+ sm: "h-5 gap-1 px-2 text-xs",
33
+ md: "h-7 gap-1.5 px-3 text-sm",
34
+ };
35
+
36
+ /*
37
+ * 状态色用文字档的语义令牌作底、ink-inverse 作字:两个主题下都 ≥ 4.5:1。
38
+ * 角色色里的 alert 红作底时,墨字白字都不够。
39
+ */
40
+ const stateClass: Record<CountdownState, string> = {
41
+ ample: "bg-success text-ink-inverse",
42
+ urgent: "bg-danger text-ink-inverse",
43
+ expired: "bg-surface-muted text-ink-secondary",
44
+ };
45
+
46
+ function pad(value: number) {
47
+ return String(value).padStart(2, "0");
48
+ }
49
+
50
+ function format(remaining: number, dayUnit: string) {
51
+ const seconds = Math.ceil(remaining / 1000);
52
+ const days = Math.floor(seconds / 86_400);
53
+ const clock = [
54
+ Math.floor((seconds % 86_400) / 3600),
55
+ Math.floor((seconds % 3600) / 60),
56
+ seconds % 60,
57
+ ]
58
+ .map(pad)
59
+ .join(":");
60
+ return days > 0 ? `${days}${dayUnit} ${clock}` : clock;
61
+ }
62
+
63
+ /**
64
+ * 倒计时:充裕时是绿色胶囊,紧迫时变红并多一个 `!`。
65
+ * 不会每秒播报——读屏拿到的是静态的截止时间,走到这里时才读当前剩余。
66
+ */
67
+ export function Countdown({
68
+ to,
69
+ urgentWithin = DAY,
70
+ expiredLabel = "已结束",
71
+ onExpire,
72
+ dayUnit = "天",
73
+ deadlineLabel,
74
+ size = "md",
75
+ className,
76
+ ...props
77
+ }: CountdownProps) {
78
+ const target = new Date(to).getTime();
79
+ const [now, setNow] = useState(() => Date.now());
80
+
81
+ useEffect(() => {
82
+ setNow(Date.now());
83
+ if (!(target > Date.now())) return;
84
+
85
+ const timer = setInterval(() => {
86
+ const current = Date.now();
87
+ setNow(current);
88
+ if (current >= target) clearInterval(timer);
89
+ }, 1000);
90
+ return () => clearInterval(timer);
91
+ }, [target]);
92
+
93
+ const remaining = Number.isNaN(target) ? 0 : Math.max(0, target - now);
94
+ const state: CountdownState =
95
+ remaining === 0
96
+ ? "expired"
97
+ : remaining <= urgentWithin
98
+ ? "urgent"
99
+ : "ample";
100
+
101
+ const onExpireRef = useRef(onExpire);
102
+ const previous = useRef(state);
103
+ useEffect(() => {
104
+ onExpireRef.current = onExpire;
105
+ });
106
+ useEffect(() => {
107
+ if (state === "expired" && previous.current !== "expired") {
108
+ onExpireRef.current?.();
109
+ }
110
+ previous.current = state;
111
+ }, [state]);
112
+
113
+ const valid = !Number.isNaN(target);
114
+
115
+ return (
116
+ <span
117
+ {...props}
118
+ data-state={state}
119
+ className={cn(
120
+ "inline-flex shrink-0 items-center rounded-full font-tech leading-none font-bold whitespace-nowrap tabular-nums",
121
+ sizeClass[size],
122
+ stateClass[state],
123
+ className,
124
+ )}
125
+ >
126
+ {state === "urgent" && <span aria-hidden="true">!</span>}
127
+ {/* 本地时间在服务端和浏览器里可能不同,这两处不做水合校验 */}
128
+ <span role="timer" aria-live="off" suppressHydrationWarning>
129
+ {state === "expired" ? expiredLabel : format(remaining, dayUnit)}
130
+ </span>
131
+ {valid && (
132
+ <span className="sr-only" suppressHydrationWarning>
133
+ {deadlineLabel ?? (
134
+ <>
135
+ 截止{" "}
136
+ <time dateTime={new Date(target).toISOString()}>
137
+ {new Date(target).toLocaleString()}
138
+ </time>
139
+ </>
140
+ )}
141
+ </span>
142
+ )}
143
+ </span>
144
+ );
145
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ Countdown,
3
+ type CountdownProps,
4
+ type CountdownSize,
5
+ type CountdownState,
6
+ } from "./Countdown";
@@ -0,0 +1,44 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export type DashIndicatorProps = Omit<ComponentProps<"span">, "children"> & {
5
+ /** 一共几项 */
6
+ count: number;
7
+ /** 当前是第几项,从 0 起 */
8
+ index: number;
9
+ };
10
+
11
+ /**
12
+ * 进度短横:一排短横线表示"共几项、在第几项"。只做指示,不可点击;
13
+ * 要能翻动,旁边配一个 `Navigator` 或一对翻页钮。
14
+ */
15
+ export function DashIndicator({
16
+ count,
17
+ index,
18
+ className,
19
+ ...props
20
+ }: DashIndicatorProps) {
21
+ const total = Math.max(0, Math.floor(count));
22
+ const current = Math.min(Math.max(Math.floor(index), 0), total - 1);
23
+
24
+ return (
25
+ <span
26
+ role="img"
27
+ aria-label={`第 ${current + 1} 项,共 ${total} 项`}
28
+ {...props}
29
+ className={cn("inline-flex items-center gap-1", className)}
30
+ >
31
+ {Array.from({ length: total }, (_, position) => (
32
+ <span
33
+ key={position}
34
+ data-current={position === current ? "" : undefined}
35
+ className={cn(
36
+ "h-0.5 transition-[width,background-color] duration-(--duration-base) ease-standard",
37
+ // 当前一根除了换色还加长一倍,不只靠颜色
38
+ position === current ? "w-8 bg-accent-ink" : "w-4 bg-line-strong",
39
+ )}
40
+ />
41
+ ))}
42
+ </span>
43
+ );
44
+ }
@@ -0,0 +1 @@
1
+ export { DashIndicator, type DashIndicatorProps } from "./DashIndicator";
@@ -0,0 +1,282 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ type ComponentProps,
5
+ type CSSProperties,
6
+ type ReactNode,
7
+ } from "react";
8
+ import { cn } from "../../lib/cn";
9
+ import { focusRing } from "../../lib/focus-ring";
10
+ import { bandCanvas, bandGap, bandRow } from "../list/band-style";
11
+ import { Sparkline } from "../sparkline/Sparkline";
12
+
13
+ export type DataRowColumns = {
14
+ /** 名称那一列的列名 */
15
+ name: ReactNode;
16
+ /** 走势列的列名。不给就没有这一列 */
17
+ trend?: ReactNode;
18
+ /** 当前值那一列的列名 */
19
+ value: ReactNode;
20
+ /** 参考值列的列名。不给就没有这一列 */
21
+ reference?: ReactNode;
22
+ };
23
+
24
+ export type DataRowListProps = Omit<ComponentProps<"div">, "children"> & {
25
+ /** 这张表的名称,读屏会读出来 */
26
+ label?: string;
27
+ /** 各列的列名;有没有走势列、参考值列也由它决定 */
28
+ columns: DataRowColumns;
29
+ /** `DataRow` */
30
+ children: ReactNode;
31
+ };
32
+
33
+ type Layout = { trend: boolean; reference: boolean };
34
+
35
+ const LayoutContext = createContext<Layout>({ trend: false, reference: false });
36
+
37
+ /*
38
+ * 列的模板。容器变窄时先收起走势列(@lg 以下),再收起参考值列(@sm 以下);
39
+ * 对应的单元格用 hidden 收起,模板跟着少一列。
40
+ */
41
+ const NARROW = "grid-cols-[minmax(0,1fr)_auto]";
42
+ const columnsClass = (layout: Layout) =>
43
+ cn(
44
+ NARROW,
45
+ layout.reference && "@sm:grid-cols-[minmax(0,1fr)_auto_auto]",
46
+ layout.trend &&
47
+ (layout.reference
48
+ ? "@lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto_auto]"
49
+ : "@lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto]"),
50
+ );
51
+
52
+ const trendCell = "hidden @lg:block";
53
+ const referenceCell = "hidden @sm:block";
54
+
55
+ /* 每一行(连同列头)都是一个子网格:对齐到表格的那一组列 */
56
+ const rowGrid = "col-span-full grid grid-cols-subgrid items-center gap-x-4";
57
+
58
+ /*
59
+ * 列名之间的短竖线画在单元格左边的缝里。截断写在里面那一层上:
60
+ * 写在单元格自己身上的话,overflow 会把竖线一起裁掉。
61
+ */
62
+ const headerCell =
63
+ "relative min-w-0 not-first:before:absolute not-first:before:top-1/2 not-first:before:-left-2 not-first:before:h-3 not-first:before:w-px not-first:before:-translate-y-1/2 not-first:before:bg-line-strong not-first:before:content-['']";
64
+
65
+ function HeaderCell({
66
+ className,
67
+ children,
68
+ }: {
69
+ className?: string;
70
+ children: ReactNode;
71
+ }) {
72
+ return (
73
+ <span role="columnheader" className={cn(headerCell, className)}>
74
+ <span className="block truncate">{children}</span>
75
+ </span>
76
+ );
77
+ }
78
+
79
+ /**
80
+ * 数据行带:浅色画布上一条条深色的行,上面一条胶囊形的列头。
81
+ * 语义是一张表格;里面只放 `DataRow`。
82
+ */
83
+ export function DataRowList({
84
+ label,
85
+ columns,
86
+ className,
87
+ children,
88
+ ...props
89
+ }: DataRowListProps) {
90
+ const layout: Layout = {
91
+ trend: columns.trend !== undefined,
92
+ reference: columns.reference !== undefined,
93
+ };
94
+
95
+ return (
96
+ <div
97
+ {...props}
98
+ className={cn("@container text-ink", bandCanvas, className)}
99
+ >
100
+ <div
101
+ role="table"
102
+ aria-label={label}
103
+ className={cn("grid", bandGap, columnsClass(layout))}
104
+ >
105
+ <div
106
+ role="row"
107
+ className={cn(
108
+ rowGrid,
109
+ // 透明的边线:数据行有一圈 1px 的线,列头也占上这 1px,两边的文字才对得齐
110
+ "min-h-6 rounded-full border border-transparent bg-surface-muted pr-3 pl-4 text-xs",
111
+ )}
112
+ >
113
+ <HeaderCell>{columns.name}</HeaderCell>
114
+ {layout.trend && (
115
+ <HeaderCell className={trendCell}>{columns.trend}</HeaderCell>
116
+ )}
117
+ <HeaderCell className="text-right">{columns.value}</HeaderCell>
118
+ {layout.reference && (
119
+ <HeaderCell className={cn(referenceCell, "text-right")}>
120
+ {columns.reference}
121
+ </HeaderCell>
122
+ )}
123
+ </div>
124
+ <LayoutContext value={layout}>{children}</LayoutContext>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ export type DataRowTone = "neutral" | "info" | "accent" | "danger";
131
+
132
+ export type DataRowProps = Omit<ComponentProps<"div">, "children"> & {
133
+ /** 这一行的名称 */
134
+ name: ReactNode;
135
+ /**
136
+ * 左缘的类目色条:一个 CSS 颜色值,通常传令牌变量(`var(--color-special)`)。
137
+ * 不要用黄色——黄色是"选中"和"收藏"
138
+ */
139
+ categoryColor?: string;
140
+ /** 收藏。只传它是一个静态的标记;同时传 `onFavoriteChange` 才是一个能点的切换按钮 */
141
+ favorite?: boolean;
142
+ onFavoriteChange?: (favorite: boolean) => void;
143
+ /** 收藏按钮的可访问名称,默认"收藏" */
144
+ favoriteLabel?: string;
145
+ /** 走势:一串数值,画成行内的小型面积图。表头没有走势列时不显示 */
146
+ series?: readonly number[];
147
+ /** 当前值。正负号连同数值一起传:颜色之外还要有符号 */
148
+ value: ReactNode;
149
+ /**
150
+ * 当前值的含义:
151
+ * - `info` 产出、正向(蓝);
152
+ * - `accent` 消耗、关注(黄);
153
+ * - `danger` 超支、异常(红),走势图也跟着变红;
154
+ * - `neutral` 不表态,默认。
155
+ */
156
+ tone?: DataRowTone;
157
+ /** 参考值(理论值、上限)。表头没有参考值列时不显示 */
158
+ reference?: ReactNode;
159
+ };
160
+
161
+ /* 行是局部暗色主题:这几档文字色按深色底取值,对比度都在 4.5:1 以上 */
162
+ const toneClass: Record<DataRowTone, string> = {
163
+ neutral: "text-ink",
164
+ info: "text-info",
165
+ accent: "text-accent-ink",
166
+ danger: "text-danger",
167
+ };
168
+
169
+ const favoriteDot = "block size-4 rounded-full";
170
+ const favoriteOn = "bg-action";
171
+ /* 未收藏是一个空心的圆环:形状也不同,不只靠颜色 */
172
+ const favoriteOff = "border-2 border-ink-tertiary";
173
+
174
+ /** 数据行带里的一行。放在 `DataRowList` 里。 */
175
+ export function DataRow({
176
+ name,
177
+ categoryColor,
178
+ favorite,
179
+ onFavoriteChange,
180
+ favoriteLabel = "收藏",
181
+ series,
182
+ value,
183
+ tone = "neutral",
184
+ reference,
185
+ className,
186
+ style,
187
+ ...props
188
+ }: DataRowProps) {
189
+ const layout = useContext(LayoutContext);
190
+ const hasFavorite = favorite !== undefined || onFavoriteChange !== undefined;
191
+ const dotClass = cn(favoriteDot, favorite ? favoriteOn : favoriteOff);
192
+
193
+ return (
194
+ <div
195
+ role="row"
196
+ // 固定的深色:行里的文字、焦点环都按深色底取值
197
+ data-theme="dark"
198
+ data-tone={tone}
199
+ data-favorite={favorite ? "" : undefined}
200
+ {...props}
201
+ style={
202
+ categoryColor
203
+ ? ({
204
+ "--data-row-category": categoryColor,
205
+ ...style,
206
+ } as CSSProperties)
207
+ : style
208
+ }
209
+ className={cn(rowGrid, bandRow, "relative min-h-11 pr-3 pl-4", className)}
210
+ >
211
+ {categoryColor && (
212
+ <span
213
+ aria-hidden="true"
214
+ data-category=""
215
+ className="absolute inset-y-0 left-0 w-1 bg-(--data-row-category)"
216
+ />
217
+ )}
218
+
219
+ <div role="rowheader" className="flex min-w-0 items-center gap-2.5">
220
+ {hasFavorite &&
221
+ (onFavoriteChange ? (
222
+ <button
223
+ type="button"
224
+ aria-label={favoriteLabel}
225
+ aria-pressed={favorite ?? false}
226
+ onClick={() => onFavoriteChange(!favorite)}
227
+ // 圆只有 16px,点击区用伪元素向外补到 36px
228
+ className={cn(
229
+ "relative shrink-0 rounded-full after:absolute after:-inset-2.5 after:content-['']",
230
+ focusRing,
231
+ )}
232
+ >
233
+ <span className={dotClass} />
234
+ </button>
235
+ ) : (
236
+ <span aria-hidden="true" className={cn(dotClass, "shrink-0")} />
237
+ ))}
238
+ <span className="min-w-0 truncate">
239
+ {name}
240
+ {favorite && !onFavoriteChange && (
241
+ <span className="sr-only">(已收藏)</span>
242
+ )}
243
+ </span>
244
+ </div>
245
+
246
+ {layout.trend && (
247
+ <div role="cell" className={cn(trendCell, "h-8")}>
248
+ {series && (
249
+ <Sparkline
250
+ data={series}
251
+ // 量值图表归蓝;只有这一行异常时换成红
252
+ tone={tone === "danger" ? "danger" : "info"}
253
+ className="h-full"
254
+ />
255
+ )}
256
+ </div>
257
+ )}
258
+
259
+ <div
260
+ role="cell"
261
+ className={cn(
262
+ "text-right font-tech font-bold whitespace-nowrap tabular-nums",
263
+ toneClass[tone],
264
+ )}
265
+ >
266
+ {value}
267
+ </div>
268
+
269
+ {layout.reference && (
270
+ <div
271
+ role="cell"
272
+ className={cn(
273
+ referenceCell,
274
+ "text-right font-tech whitespace-nowrap text-ink-secondary tabular-nums",
275
+ )}
276
+ >
277
+ {reference}
278
+ </div>
279
+ )}
280
+ </div>
281
+ );
282
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ DataRow,
3
+ DataRowList,
4
+ type DataRowColumns,
5
+ type DataRowListProps,
6
+ type DataRowProps,
7
+ type DataRowTone,
8
+ } from "./DataRow";
@@ -0,0 +1,159 @@
1
+ import { Popover as BasePopover } from "@base-ui/react/popover";
2
+ import { useRef, type ReactNode, type Ref } from "react";
3
+ import { usePortalScope } from "../../hooks/usePortalScope";
4
+ import { CalendarIcon } from "../../icons/CalendarIcon";
5
+ import { StatusDanger } from "../../icons/StatusDanger";
6
+ import { cn } from "../../lib/cn";
7
+ import { focusRing } from "../../lib/focus-ring";
8
+ import { menuPanel } from "../dropdown-menu/menu-style";
9
+ import {
10
+ controlBox,
11
+ controlIconSize,
12
+ controlSize,
13
+ type ControlSize,
14
+ type ControlVariant,
15
+ } from "../input/control-box";
16
+
17
+ /*
18
+ * 日期选择和日期范围共用的那层壳:输入框的外框 + 一个按钮 + 一块气泡面板。
19
+ * 不对外导出。面板里放什么(月历、底下那一行)由两个控件各自给。
20
+ */
21
+
22
+ /** 和日期块、微标行同一种写法:`2026.10.09` */
23
+ export const dottedDate = (date: string) => date.replaceAll("-", ".");
24
+
25
+ /** 面板底下那一行里的文字钮 */
26
+ export const dateFooterButton = cn(
27
+ "px-2 py-1 text-sm font-medium text-ink underline underline-offset-4 hover:text-ink-secondary",
28
+ "disabled:cursor-not-allowed disabled:text-ink-disabled disabled:no-underline",
29
+ focusRing,
30
+ );
31
+
32
+ export type DateFieldProps = {
33
+ /** 外框里显示的值;没选时传 `null`,显示 `placeholder` */
34
+ display: ReactNode | null;
35
+ placeholder: ReactNode;
36
+ /** 随表单提交用的隐藏字段 */
37
+ fields?: ReactNode;
38
+ variant: ControlVariant;
39
+ size: ControlSize;
40
+ invalid: boolean;
41
+ disabled: boolean;
42
+ required: boolean;
43
+ open: boolean;
44
+ onOpenChange: (open: boolean) => void;
45
+ /** 面板的可访问名称 */
46
+ panelLabel: string;
47
+ id?: string;
48
+ "aria-label"?: string;
49
+ "aria-describedby"?: string;
50
+ /** 给外框 */
51
+ className?: string;
52
+ /** 给触发按钮 */
53
+ ref?: Ref<HTMLButtonElement>;
54
+ /** 面板里的东西 */
55
+ children: ReactNode;
56
+ };
57
+
58
+ export function DateField({
59
+ display,
60
+ placeholder,
61
+ fields,
62
+ variant,
63
+ size,
64
+ invalid,
65
+ disabled,
66
+ required,
67
+ open,
68
+ onOpenChange,
69
+ panelLabel,
70
+ id,
71
+ "aria-label": ariaLabel,
72
+ "aria-describedby": describedBy,
73
+ className,
74
+ ref,
75
+ children,
76
+ }: DateFieldProps) {
77
+ const { anchorRef, portalRef } = usePortalScope();
78
+ const boxRef = useRef<HTMLDivElement>(null);
79
+ const popupRef = useRef<HTMLDivElement>(null);
80
+ const iconSize = controlIconSize[size];
81
+
82
+ return (
83
+ <BasePopover.Root open={open} onOpenChange={(next) => onOpenChange(next)}>
84
+ <div
85
+ ref={(node) => {
86
+ boxRef.current = node;
87
+ anchorRef(node);
88
+ }}
89
+ data-variant={variant}
90
+ data-size={size}
91
+ data-invalid={invalid ? "" : undefined}
92
+ className={cn(
93
+ controlBox({ variant, invalid, disabled, readOnly: false }),
94
+ // 面板开着的时候边线保持聚焦的墨色
95
+ !disabled && !invalid && "has-data-popup-open:border-ink",
96
+ controlSize[size],
97
+ className,
98
+ )}
99
+ >
100
+ <BasePopover.Trigger
101
+ ref={ref}
102
+ id={id}
103
+ disabled={disabled}
104
+ aria-label={ariaLabel}
105
+ aria-invalid={invalid || undefined}
106
+ aria-required={required || undefined}
107
+ aria-describedby={describedBy}
108
+ className="flex h-full min-w-0 flex-1 cursor-default items-center gap-2 px-3 text-left outline-none disabled:cursor-not-allowed"
109
+ >
110
+ {display === null ? (
111
+ <span className="min-w-0 flex-1 truncate text-ink-tertiary">
112
+ {placeholder}
113
+ </span>
114
+ ) : (
115
+ <span className="min-w-0 flex-1 truncate font-tech tabular-nums">
116
+ {display}
117
+ </span>
118
+ )}
119
+ {invalid && (
120
+ <StatusDanger size={iconSize} className="shrink-0 text-danger" />
121
+ )}
122
+ <CalendarIcon
123
+ size={iconSize}
124
+ className={cn(
125
+ "shrink-0",
126
+ disabled ? "text-ink-disabled" : "text-ink-secondary",
127
+ )}
128
+ />
129
+ </BasePopover.Trigger>
130
+ {fields}
131
+ </div>
132
+
133
+ <BasePopover.Portal ref={portalRef}>
134
+ <BasePopover.Positioner
135
+ // 面板对着外框落位,不是对着里面的按钮:否则差出一条边线的宽度
136
+ anchor={boxRef}
137
+ side="bottom"
138
+ align="start"
139
+ sideOffset={4}
140
+ className="z-(--z-overlay) outline-none"
141
+ >
142
+ <BasePopover.Popup
143
+ ref={popupRef}
144
+ aria-label={panelLabel}
145
+ // 打开时焦点落在月历里能被 Tab 停到的那一天:选中的,没选时是今天
146
+ initialFocus={() =>
147
+ popupRef.current?.querySelector<HTMLElement>(
148
+ '[data-date][tabindex="0"]',
149
+ ) ?? true
150
+ }
151
+ className={cn(menuPanel, "flex flex-col gap-2 p-2")}
152
+ >
153
+ {children}
154
+ </BasePopover.Popup>
155
+ </BasePopover.Positioner>
156
+ </BasePopover.Portal>
157
+ </BasePopover.Root>
158
+ );
159
+ }