@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,219 @@
1
+ import { Slider as BaseSlider } from "@base-ui/react/slider";
2
+ import { useMemo, type ReactNode } from "react";
3
+ import { cn } from "../../lib/cn";
4
+ import { focusRingWithin } from "../../lib/focus-ring";
5
+ import { useFieldContext, useFieldControl } from "../field/Field";
6
+
7
+ export type SliderSize = "sm" | "md";
8
+
9
+ export type SliderMark = {
10
+ value: number;
11
+ /** 刻度下面的标注;不传就只有一条短线 */
12
+ label?: ReactNode;
13
+ };
14
+
15
+ type SliderCommonProps = {
16
+ /** 下限,默认 0 */
17
+ min?: number;
18
+ /** 上限,默认 100 */
19
+ max?: number;
20
+ /** 一步走多少,默认 1 */
21
+ step?: number;
22
+ /** `PageUp` / `PageDown`、`Shift` + 方向键一次走多少,默认 10 */
23
+ largeStep?: number;
24
+ /** 整个控件占 32 / 40px 高,默认 `md` */
25
+ size?: SliderSize;
26
+ /** 在右侧显示当前值 */
27
+ showValue?: boolean;
28
+ /** 数值怎么写:`Intl.NumberFormat` 的选项(百分比、单位、小数位) */
29
+ format?: Intl.NumberFormatOptions;
30
+ /** 轨道下面的刻度。只是标注,不改变步长 */
31
+ marks?: readonly SliderMark[];
32
+ /** 表单字段名:值随表单提交 */
33
+ name?: string;
34
+ disabled?: boolean;
35
+ /** 不在 `Field` 里时必须给 */
36
+ "aria-label"?: string;
37
+ "aria-describedby"?: string;
38
+ className?: string;
39
+ };
40
+
41
+ export type SliderSingleProps = SliderCommonProps & {
42
+ value?: number;
43
+ defaultValue?: number;
44
+ /** 拖动的每一步都触发 */
45
+ onValueChange?: (value: number) => void;
46
+ /** 松手(或一次按键)之后触发:要发请求的事放这里 */
47
+ onValueCommitted?: (value: number) => void;
48
+ thumbLabels?: never;
49
+ minStepsBetweenValues?: never;
50
+ };
51
+
52
+ export type SliderRangeProps = SliderCommonProps & {
53
+ /** 传两个数就是范围滑块 */
54
+ value?: readonly [number, number];
55
+ defaultValue?: readonly [number, number];
56
+ onValueChange?: (value: [number, number]) => void;
57
+ onValueCommitted?: (value: [number, number]) => void;
58
+ /** 两个滑块各自的名称,默认"最小值""最大值" */
59
+ thumbLabels?: readonly [string, string];
60
+ /** 两个滑块之间至少隔几步,默认 0 */
61
+ minStepsBetweenValues?: number;
62
+ };
63
+
64
+ export type SliderProps = SliderSingleProps | SliderRangeProps;
65
+
66
+ const controlSize: Record<SliderSize, string> = {
67
+ sm: "h-8",
68
+ md: "h-10",
69
+ };
70
+
71
+ /*
72
+ * 轨道两端各缩进半个滑块(6px):滑块到头时它的外缘正好和别的字段对齐,不伸出去。
73
+ * 刻度按同样的算法落位,才对得上滑块的中心。
74
+ * (没有用基元的 thumbAlignment="edge":它要先量一遍尺寸才显示滑块,服务端渲染时是空的)
75
+ */
76
+ const THUMB_HALF = 6;
77
+ const markLeft = (ratio: number) =>
78
+ `calc(${THUMB_HALF}px + (100% - ${THUMB_HALF * 2}px) * ${ratio})`;
79
+
80
+ const thumb = [
81
+ "relative h-5 w-3 bg-surface-inverse shadow-[0_0_0_2px_var(--ef-surface)]",
82
+ // 触屏的点击区补到 40 × 40px
83
+ "before:absolute before:-inset-x-3.5 before:-inset-y-2.5 before:content-['']",
84
+ // 按住时正中亮一条强调色细线
85
+ "after:absolute after:inset-y-1 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:bg-accent-ink-inverse after:opacity-0 after:content-[''] data-dragging:after:opacity-100",
86
+ // 菱形方案:滑块是一个 14px 的菱形,按住时中间亮一个小方块
87
+ "choice-diamond:size-3.5 choice-diamond:rotate-45 choice-diamond:after:inset-1 choice-diamond:after:w-auto choice-diamond:after:translate-x-0",
88
+ "data-disabled:bg-ink-disabled",
89
+ focusRingWithin,
90
+ ].join(" ");
91
+
92
+ /**
93
+ * 滑块:在一个范围里拖着选一个数。要的是确切的数时用 `Stepper`。
94
+ * 每个滑块都要有名称:放进 `Field`,或者自己传 `aria-label`。
95
+ */
96
+ export function Slider(props: SliderProps) {
97
+ const {
98
+ min = 0,
99
+ max = 100,
100
+ step = 1,
101
+ largeStep = 10,
102
+ size = "md",
103
+ showValue = false,
104
+ format,
105
+ marks,
106
+ name,
107
+ disabled: disabledProp,
108
+ "aria-label": ariaLabel,
109
+ "aria-describedby": describedByProp,
110
+ className,
111
+ } = props;
112
+ const field = useFieldContext();
113
+ const { disabled, "aria-describedby": describedBy } = useFieldControl({
114
+ disabled: disabledProp,
115
+ "aria-describedby": describedByProp,
116
+ });
117
+ const labelledBy = ariaLabel ? undefined : field?.labelId;
118
+ const range = Array.isArray(props.value ?? props.defaultValue);
119
+ const thumbLabels = props.thumbLabels ?? (["最小值", "最大值"] as const);
120
+
121
+ // 数值区按最宽的那个值留宽:拖动时轨道不跟着伸缩
122
+ const valueWidth = useMemo(() => {
123
+ const formatter = new Intl.NumberFormat(undefined, format);
124
+ const widest = Math.max(
125
+ formatter.format(min).length,
126
+ formatter.format(max).length,
127
+ );
128
+ return `${range ? widest * 2 + 3 : widest}ch`;
129
+ }, [format, min, max, range]);
130
+
131
+ return (
132
+ <BaseSlider.Root
133
+ value={props.value}
134
+ defaultValue={props.defaultValue}
135
+ onValueChange={(next) =>
136
+ (props.onValueChange as ((value: unknown) => void) | undefined)?.(next)
137
+ }
138
+ onValueCommitted={(next) =>
139
+ (props.onValueCommitted as ((value: unknown) => void) | undefined)?.(
140
+ next,
141
+ )
142
+ }
143
+ min={min}
144
+ max={max}
145
+ step={step}
146
+ largeStep={largeStep}
147
+ minStepsBetweenValues={props.minStepsBetweenValues}
148
+ thumbCollisionBehavior="none"
149
+ format={format}
150
+ name={name}
151
+ disabled={disabled}
152
+ // 范围滑块是一个有名称的分组,里面两个滑块各有各的名称
153
+ {...(range && {
154
+ "aria-label": ariaLabel,
155
+ "aria-labelledby": labelledBy,
156
+ })}
157
+ data-size={size}
158
+ className={cn("flex w-full items-start gap-3 text-ink", className)}
159
+ >
160
+ <div className="min-w-0 flex-1">
161
+ <BaseSlider.Control
162
+ className={cn(
163
+ "flex touch-none items-center px-1.5 select-none data-disabled:cursor-not-allowed",
164
+ controlSize[size],
165
+ )}
166
+ >
167
+ <BaseSlider.Track className="h-1 w-full bg-line">
168
+ <BaseSlider.Indicator className="bg-ink data-disabled:bg-ink-disabled" />
169
+ {(range ? [0, 1] : [0]).map((index) => (
170
+ <BaseSlider.Thumb
171
+ key={index}
172
+ index={index}
173
+ {...(range
174
+ ? { "aria-label": thumbLabels[index] }
175
+ : { "aria-label": ariaLabel, "aria-labelledby": labelledBy })}
176
+ aria-describedby={describedBy}
177
+ className={thumb}
178
+ />
179
+ ))}
180
+ </BaseSlider.Track>
181
+ </BaseSlider.Control>
182
+
183
+ {marks && marks.length > 0 && (
184
+ <div aria-hidden="true" className="relative h-6 text-xs">
185
+ {marks.map((mark) => (
186
+ <span
187
+ key={mark.value}
188
+ style={{ left: markLeft((mark.value - min) / (max - min)) }}
189
+ className={cn(
190
+ "absolute top-0 flex flex-col gap-0.5 whitespace-nowrap text-ink-secondary before:h-1.5 before:w-px before:bg-line-strong before:content-['']",
191
+ // 两端的标注朝里排,不伸到控件外面去;短线仍然对着滑块的中心
192
+ mark.value <= min
193
+ ? "items-start"
194
+ : mark.value >= max
195
+ ? "-translate-x-full items-end"
196
+ : "-translate-x-1/2 items-center",
197
+ )}
198
+ >
199
+ {mark.label}
200
+ </span>
201
+ ))}
202
+ </div>
203
+ )}
204
+ </div>
205
+
206
+ {showValue && (
207
+ <BaseSlider.Value
208
+ style={{ minWidth: valueWidth }}
209
+ className={cn(
210
+ "flex shrink-0 items-center justify-end font-tech text-sm font-bold tabular-nums data-disabled:text-ink-disabled",
211
+ controlSize[size],
212
+ )}
213
+ >
214
+ {(formatted) => formatted.join(" – ")}
215
+ </BaseSlider.Value>
216
+ )}
217
+ </BaseSlider.Root>
218
+ );
219
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ Slider,
3
+ type SliderMark,
4
+ type SliderProps,
5
+ type SliderRangeProps,
6
+ type SliderSingleProps,
7
+ type SliderSize,
8
+ } from "./Slider";
@@ -0,0 +1,122 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export type SparklineVariant = "area" | "line";
5
+ export type SparklineTone = "info" | "danger" | "accent" | "neutral";
6
+
7
+ export type SparklineProps = Omit<ComponentProps<"svg">, "children"> & {
8
+ /** 一串数值,按先后顺序。少于两个点时什么都不画 */
9
+ data: readonly number[];
10
+ /** `area` 一块填充的面积,默认;`line` 一条折线 */
11
+ variant?: SparklineVariant;
12
+ /**
13
+ * - `info` 量值图表的蓝,默认;
14
+ * - `danger` 超支、异常的红;
15
+ * - `accent` 强调色;
16
+ * - `neutral` 次要的墨色。
17
+ */
18
+ tone?: SparklineTone;
19
+ /** 纵向范围的下端。默认从数据的最小值往下再留四分之一,让最低的那一点也有厚度 */
20
+ min?: number;
21
+ /** 纵向范围的上端。默认是数据的最大值 */
22
+ max?: number;
23
+ /** 传了它就是一张有名称的图;不传是纯装饰,对读屏隐藏 */
24
+ label?: string;
25
+ };
26
+
27
+ /* 画在一个 100 × 100 的格子里,再由 SVG 拉伸到实际的盒子 */
28
+ const SIZE = 100;
29
+
30
+ /** 保留两位小数:路径短一点,快照也稳定 */
31
+ const round = (value: number) => Math.round(value * 100) / 100;
32
+
33
+ /**
34
+ * 把一串数值换算成折线与面积的路径(100 × 100 的坐标系,y 向下)。
35
+ * 少于两个点时返回 `null`。
36
+ */
37
+ export function sparklinePath(
38
+ data: readonly number[],
39
+ range: { min?: number; max?: number } = {},
40
+ ): { line: string; area: string } | null {
41
+ if (data.length < 2) return null;
42
+
43
+ const low = Math.min(...data);
44
+ const high = Math.max(...data);
45
+ const max = range.max ?? high;
46
+ const min = range.min ?? low - (high - low) * 0.25;
47
+ // 所有的值都一样(或范围给反了)时没有高低可言,画在正中
48
+ const span = max - min;
49
+
50
+ const points = data.map((value, index) => {
51
+ const x = (index / (data.length - 1)) * SIZE;
52
+ const ratio = span > 0 ? (value - min) / span : 0.5;
53
+ const y = SIZE - Math.min(Math.max(ratio, 0), 1) * SIZE;
54
+ return `${round(x)} ${round(y)}`;
55
+ });
56
+
57
+ const line = `M${points.join("L")}`;
58
+ return { line, area: `${line}L${SIZE} ${SIZE}L0 ${SIZE}Z` };
59
+ }
60
+
61
+ /* data 与 alert 是不随主题变的角色色:白底和深色行带上都认得出 */
62
+ const areaClass: Record<SparklineTone, string> = {
63
+ info: "fill-data/75",
64
+ danger: "fill-alert/75",
65
+ accent: "fill-accent-ink/75",
66
+ neutral: "fill-ink-secondary/75",
67
+ };
68
+
69
+ const lineClass: Record<SparklineTone, string> = {
70
+ info: "stroke-data",
71
+ danger: "stroke-alert",
72
+ accent: "stroke-accent-ink",
73
+ neutral: "stroke-ink-secondary",
74
+ };
75
+
76
+ /**
77
+ * 行内的小型图表:没有坐标轴、网格和图例,只有一块面积或一条折线。
78
+ * 撑满给它的盒子(默认高 32px、通宽)。它旁边应该有数字——图只是帮着看趋势。
79
+ */
80
+ export function Sparkline({
81
+ data,
82
+ variant = "area",
83
+ tone = "info",
84
+ min,
85
+ max,
86
+ label,
87
+ className,
88
+ ...props
89
+ }: SparklineProps) {
90
+ const path = sparklinePath(data, { min, max });
91
+
92
+ return (
93
+ <svg
94
+ viewBox={`0 0 ${SIZE} ${SIZE}`}
95
+ preserveAspectRatio="none"
96
+ role={label ? "img" : undefined}
97
+ aria-label={label}
98
+ aria-hidden={label ? undefined : "true"}
99
+ focusable="false"
100
+ data-variant={variant}
101
+ data-tone={tone}
102
+ {...props}
103
+ // 折线贴着上下边缘时,线宽的一半会落在盒子外面
104
+ className={cn("block h-8 w-full overflow-visible", className)}
105
+ >
106
+ {path &&
107
+ (variant === "area" ? (
108
+ <path d={path.area} className={areaClass[tone]} />
109
+ ) : (
110
+ <path
111
+ d={path.line}
112
+ fill="none"
113
+ strokeWidth={1.5}
114
+ strokeLinejoin="miter"
115
+ // 图被横向拉伸了,线宽不跟着变
116
+ vectorEffect="non-scaling-stroke"
117
+ className={lineClass[tone]}
118
+ />
119
+ ))}
120
+ </svg>
121
+ );
122
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ Sparkline,
3
+ type SparklineProps,
4
+ type SparklineTone,
5
+ type SparklineVariant,
6
+ } from "./Sparkline";
@@ -0,0 +1,64 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export type SpinnerSize = "sm" | "md";
5
+
6
+ export type SpinnerProps = Omit<ComponentProps<"span">, "children"> & {
7
+ /** 12px 或 16px。要别的尺寸用 `className` 给 `size-*` */
8
+ size?: SpinnerSize;
9
+ /**
10
+ * 读屏听到的话,默认"加载中"。
11
+ * 旁边已经有文字说明正在加载时传 `null`:这时它只是装饰,对读屏隐藏。
12
+ */
13
+ label?: ReactNode;
14
+ };
15
+
16
+ const sizeClass: Record<SpinnerSize, string> = {
17
+ sm: "size-3",
18
+ md: "size-4",
19
+ };
20
+
21
+ // 系统开启"减少动态效果"时停成一个 45° 的菱形,和列表里的菱形记号是同一个样子
22
+ const square =
23
+ "animate-spin bg-current motion-reduce:rotate-45 motion-reduce:animate-none";
24
+
25
+ /**
26
+ * 行内加载指示:一个匀速旋转的小方块,颜色继承文字。
27
+ * 圆环形的用 `<ProgressRing size={16} />`;整块内容加载用 `Skeleton`。
28
+ */
29
+ export function Spinner({
30
+ size = "md",
31
+ label = "加载中",
32
+ className,
33
+ ...props
34
+ }: SpinnerProps) {
35
+ if (label === null) {
36
+ return (
37
+ <span
38
+ {...props}
39
+ aria-hidden="true"
40
+ className={cn(
41
+ "inline-block shrink-0 align-middle",
42
+ square,
43
+ sizeClass[size],
44
+ className,
45
+ )}
46
+ />
47
+ );
48
+ }
49
+
50
+ return (
51
+ <span
52
+ {...props}
53
+ role="status"
54
+ className={cn(
55
+ "inline-flex shrink-0 align-middle",
56
+ sizeClass[size],
57
+ className,
58
+ )}
59
+ >
60
+ <span aria-hidden="true" className={cn("block size-full", square)} />
61
+ <span className="sr-only">{label}</span>
62
+ </span>
63
+ );
64
+ }
@@ -0,0 +1 @@
1
+ export { Spinner, type SpinnerProps, type SpinnerSize } from "./Spinner";
@@ -0,0 +1,79 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { Tag } from "../tag/Tag";
4
+
5
+ export type StatSize = "md" | "lg";
6
+ export type StatTrend = "up" | "down";
7
+
8
+ export type StatProps = Omit<ComponentProps<"div">, "children"> & {
9
+ /** 微标,说明这是什么数。前面的 `//` 由组件加,不用自己写 */
10
+ label: ReactNode;
11
+ /** 主角:一个需要被一眼看到的数字 */
12
+ value: ReactNode;
13
+ /** 单位,小一号、与数字基线对齐 */
14
+ unit?: ReactNode;
15
+ /** 增量,连同正负号一起传,如 `+6%`、`−2%` */
16
+ delta?: ReactNode;
17
+ /** `up` 渲染为增益签,默认;`down` 渲染为危险色文字 */
18
+ trend?: StatTrend;
19
+ /** 并排几个时,让最重要的那个用 `lg` */
20
+ size?: StatSize;
21
+ };
22
+
23
+ const valueSize: Record<StatSize, string> = {
24
+ md: "text-4xl",
25
+ lg: "text-5xl",
26
+ };
27
+
28
+ /** 统计块:不装进卡片,只用左缘一条墨色竖线。 */
29
+ export function Stat({
30
+ label,
31
+ value,
32
+ unit,
33
+ delta,
34
+ trend = "up",
35
+ size = "md",
36
+ className,
37
+ ...props
38
+ }: StatProps) {
39
+ const hasDelta = delta !== undefined && delta !== null;
40
+
41
+ return (
42
+ <div
43
+ {...props}
44
+ data-size={size}
45
+ className={cn("min-w-0 border-l-4 border-ink pl-4 text-ink", className)}
46
+ >
47
+ <p className="font-tech text-xs tracking-label text-ink-secondary">
48
+ <span aria-hidden="true">{"// "}</span>
49
+ {label}
50
+ </p>
51
+ <p className="mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1">
52
+ <span
53
+ className={cn(
54
+ "min-w-0 font-tech leading-none font-medium wrap-anywhere tabular-nums",
55
+ valueSize[size],
56
+ )}
57
+ >
58
+ {value}
59
+ </span>
60
+ {unit !== undefined && unit !== null && (
61
+ <span className="text-sm text-ink-secondary">{unit}</span>
62
+ )}
63
+ {hasDelta &&
64
+ (trend === "down" ? (
65
+ <span
66
+ data-trend="down"
67
+ className="font-tech text-sm font-bold text-danger tabular-nums"
68
+ >
69
+ {delta}
70
+ </span>
71
+ ) : (
72
+ <Tag variant="gain" size="sm" numeric data-trend="up">
73
+ {delta}
74
+ </Tag>
75
+ ))}
76
+ </p>
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1 @@
1
+ export { Stat, type StatProps, type StatSize, type StatTrend } from "./Stat";