@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,120 @@
1
+ import type { ChangeEvent, ComponentProps, ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { focusRing } from "../../lib/focus-ring";
4
+ import { choiceControl, choiceLabel } from "../checkbox/choice-style";
5
+
6
+ type DualLabels =
7
+ | { offLabel?: undefined; onLabel?: undefined }
8
+ | {
9
+ /** 双标签开关:关闭时的值,写在轨道左半(如 `2D`) */
10
+ offLabel: ReactNode;
11
+ /** 双标签开关:开启时的值,写在轨道右半(如 `3D`) */
12
+ onLabel: ReactNode;
13
+ };
14
+
15
+ export type SwitchProps = Omit<ComponentProps<"input">, "type" | "size"> & {
16
+ onCheckedChange?: (checked: boolean) => void;
17
+ /** 右侧的文字标签。不传时必须自己给 `aria-label` */
18
+ children?: ReactNode;
19
+ } & DualLabels;
20
+
21
+ const trackTransition =
22
+ "transition-colors duration-(--duration-fast) ease-standard";
23
+ const dualLabel =
24
+ "pointer-events-none relative min-w-9 px-3 text-center transition-colors duration-(--duration-fast) ease-standard peer-disabled:text-ink-disabled";
25
+
26
+ /**
27
+ * 开关:胶囊轨道 + 圆形旋钮,立即生效的设置用它(需要"保存"才生效的用复选框)。
28
+ * 原生 `<input type="checkbox" role="switch">`,受控用 `checked`、非受控用 `defaultChecked`。
29
+ * `className` 给整行,其余属性与 `ref` 给 `<input>`。
30
+ *
31
+ * 两个值都要显示时(`2D` / `3D`)同时传 `offLabel` 与 `onLabel`:两个值并排写在轨道上,
32
+ * 旋钮盖住当前的那一个。这时名称要写成"开 = 第二个值"的说法,如 `aria-label="三维视图"`。
33
+ */
34
+ export function Switch({
35
+ onCheckedChange,
36
+ onChange,
37
+ offLabel,
38
+ onLabel,
39
+ className,
40
+ children,
41
+ ...props
42
+ }: SwitchProps) {
43
+ const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
44
+ onChange?.(event);
45
+ onCheckedChange?.(event.target.checked);
46
+ };
47
+
48
+ const dual = offLabel !== undefined && onLabel !== undefined;
49
+
50
+ const control = dual ? (
51
+ // 两格等宽,按较宽的那个值定宽
52
+ <span className="relative inline-grid h-8 grid-cols-2 items-center font-tech text-xs leading-none font-bold">
53
+ <input
54
+ {...props}
55
+ type="checkbox"
56
+ role="switch"
57
+ onChange={handleChange}
58
+ className={cn(
59
+ // 两个值都是有效值,轨道不随开关变色:反转块的 80%,压在图像上时略微透出底下
60
+ "peer absolute inset-0 size-full appearance-none rounded-full bg-surface-inverse/80 group-hover:bg-surface-inverse disabled:cursor-not-allowed disabled:bg-surface-muted",
61
+ trackTransition,
62
+ focusRing,
63
+ )}
64
+ />
65
+ <span
66
+ aria-hidden="true"
67
+ className={cn(
68
+ "pointer-events-none absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-surface-muted",
69
+ "transition-[translate] duration-(--duration-fast) ease-standard",
70
+ "peer-checked:translate-x-full peer-disabled:bg-surface-raised",
71
+ )}
72
+ />
73
+ {/* 旋钮上的值用墨色,轨道上的另一个值降到 70% */}
74
+ <span
75
+ aria-hidden="true"
76
+ data-value="off"
77
+ className={cn(dualLabel, "text-ink peer-checked:text-ink-inverse/70")}
78
+ >
79
+ {offLabel}
80
+ </span>
81
+ <span
82
+ aria-hidden="true"
83
+ data-value="on"
84
+ className={cn(dualLabel, "text-ink-inverse/70 peer-checked:text-ink")}
85
+ >
86
+ {onLabel}
87
+ </span>
88
+ </span>
89
+ ) : (
90
+ <span className="relative inline-flex h-7 w-13">
91
+ <input
92
+ {...props}
93
+ type="checkbox"
94
+ role="switch"
95
+ onChange={handleChange}
96
+ className={cn(
97
+ // 开:反转块作轨道。颜色只点在旋钮上,不是整条黄色轨道
98
+ "peer h-7 w-13 appearance-none rounded-full bg-line-strong group-hover:bg-ink-tertiary checked:bg-surface-inverse disabled:cursor-not-allowed disabled:bg-surface-muted",
99
+ trackTransition,
100
+ focusRing,
101
+ )}
102
+ />
103
+ <span
104
+ aria-hidden="true"
105
+ className={cn(
106
+ "pointer-events-none absolute top-1 left-1 size-5 rounded-full bg-surface-raised",
107
+ "transition-[translate,background-color] duration-(--duration-fast) ease-standard",
108
+ "peer-checked:translate-x-6 peer-checked:bg-accent-ink-inverse peer-disabled:bg-ink-disabled",
109
+ )}
110
+ />
111
+ </span>
112
+ );
113
+
114
+ return (
115
+ <label className={cn(choiceLabel, "gap-3", className)}>
116
+ <span className={choiceControl}>{control}</span>
117
+ {children !== undefined && <span className="min-w-0">{children}</span>}
118
+ </label>
119
+ );
120
+ }
@@ -0,0 +1 @@
1
+ export { Switch, type SwitchProps } from "./Switch";
@@ -0,0 +1,559 @@
1
+ import {
2
+ Children,
3
+ createContext,
4
+ isValidElement,
5
+ useContext,
6
+ useEffect,
7
+ useId,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ type ComponentProps,
13
+ type MouseEvent,
14
+ type ReactNode,
15
+ type RefObject,
16
+ } from "react";
17
+ import { useControllableState } from "../../hooks/useControllableState";
18
+ import { useOverflowing } from "../../hooks/useOverflowing";
19
+ import { TriangleRight } from "../../icons/TriangleRight";
20
+ import { cn } from "../../lib/cn";
21
+ import { focusRing, focusRingInset } from "../../lib/focus-ring";
22
+ import { mergeRefs } from "../../lib/merge-refs";
23
+
24
+ export type TableHeaderVariant = "band" | "muted";
25
+ export type TableSize = "sm" | "md";
26
+ export type TableSortDirection = "ascending" | "descending";
27
+ export type TableAlign = "start" | "center" | "end";
28
+
29
+ type TableContextValue = {
30
+ headerVariant: TableHeaderVariant;
31
+ size: TableSize;
32
+ sticky: boolean;
33
+ stickyHeader: boolean;
34
+ ruled: boolean;
35
+ };
36
+
37
+ const TableContext = createContext<TableContextValue>({
38
+ headerVariant: "band",
39
+ size: "md",
40
+ sticky: false,
41
+ stickyHeader: false,
42
+ ruled: false,
43
+ });
44
+
45
+ /** 单元格要知道自己在表头里还是表身里 */
46
+ const SectionContext = createContext<"head" | "body">("body");
47
+
48
+ /** 能展开的行交给里面的展开钮 */
49
+ const RowContext = createContext<{
50
+ expanded: boolean;
51
+ toggle: () => void;
52
+ detailId: string;
53
+ } | null>(null);
54
+
55
+ export type TableProps = Omit<ComponentProps<"table">, "aria-label"> & {
56
+ /** 表格的名称。横向能滚动时,滚动的那一块也用它 */
57
+ label: string;
58
+ /**
59
+ * 表头的画法:
60
+ * - `band` 反转的标题带(亮色下墨底白字),默认,和面板的标题带是同一个样子。
61
+ * 表头是一个 `data-theme="inverse"` 的局部主题,里面可以照常放复选框;
62
+ * - `muted` 浅灰底墨字,页面上已经有很多深色块时用。
63
+ */
64
+ headerVariant?: TableHeaderVariant;
65
+ /** 行高 36 / 44px,默认 `md` */
66
+ size?: TableSize;
67
+ /** 横向滚动时冻结第一列。列多、窄屏上要滚着看时打开 */
68
+ stickyFirstColumn?: boolean;
69
+ /**
70
+ * 表身在容器里纵向滚动时,表头留在容器的上沿。
71
+ * 容器要有一个高度上限才滚得起来:`className="max-h-96"`
72
+ */
73
+ stickyHeader?: boolean;
74
+ /** 每隔五行加重一条线。行数很多时帮眼睛数行 */
75
+ ruled?: boolean;
76
+ /** 给外面那层横向滚动的容器;其余属性给 `<table>` */
77
+ className?: string;
78
+ };
79
+
80
+ /**
81
+ * 表格:原生 `<table>` 套样式。里面放 `TableHead`、`TableBody`、`TableRow`、
82
+ * `TableHeaderCell`、`TableCell`。
83
+ *
84
+ * 放不下时在自己的容器里横向滚动,不撑破页面;给容器一个高度上限,它也能纵向滚动。整行不可点:
85
+ * 要进详情把名称写成链接,要勾选在第一列放 `Checkbox`,要展开明细给行传 `detail`、
86
+ * 在格子里放 `TableExpander`。
87
+ */
88
+ export function Table({
89
+ label,
90
+ headerVariant = "band",
91
+ size = "md",
92
+ stickyFirstColumn = false,
93
+ stickyHeader = false,
94
+ ruled = false,
95
+ className,
96
+ ...props
97
+ }: TableProps) {
98
+ const [scrollerRef, overflowing] = useOverflowing();
99
+ const scrollable = overflowing.x || overflowing.y;
100
+
101
+ // 展开的明细要钉在容器看得见的那一段里,它得知道这一段有多宽
102
+ useEffect(() => {
103
+ const node = scrollerRef.current;
104
+ if (!node || typeof ResizeObserver === "undefined") return;
105
+ const write = () =>
106
+ node.style.setProperty("--table-viewport", `${node.clientWidth}px`);
107
+ write();
108
+ const observer = new ResizeObserver(write);
109
+ observer.observe(node);
110
+ return () => observer.disconnect();
111
+ }, [scrollerRef]);
112
+ const context = useMemo(
113
+ () => ({
114
+ headerVariant,
115
+ size,
116
+ sticky: stickyFirstColumn,
117
+ stickyHeader,
118
+ ruled,
119
+ }),
120
+ [headerVariant, size, stickyFirstColumn, stickyHeader, ruled],
121
+ );
122
+
123
+ return (
124
+ <TableContext value={context}>
125
+ <div
126
+ ref={scrollerRef}
127
+ // 冻结列右缘的那条线只看横向
128
+ data-overflowing={overflowing.x ? "" : undefined}
129
+ // 只有真的能滚动时才是一个能聚焦的区域:键盘要能滚它,
130
+ // 但不溢出的时候不该白占一个 Tab 停靠点
131
+ {...(scrollable && {
132
+ role: "region",
133
+ "aria-label": label,
134
+ tabIndex: 0,
135
+ })}
136
+ className={cn(
137
+ // 冻结列的底要知道表格压在什么颜色上;压在别的底色上时用 className 改这个变量
138
+ "group/table isolate overflow-auto [--table-surface:var(--ef-surface)]",
139
+ focusRingInset,
140
+ className,
141
+ )}
142
+ >
143
+ <table
144
+ {...props}
145
+ aria-label={label}
146
+ data-size={size}
147
+ data-header-variant={headerVariant}
148
+ // 不用 border-collapse:合并的边线不跟着冻结的单元格走
149
+ className="w-full border-separate border-spacing-0 text-left text-sm text-ink"
150
+ />
151
+ </div>
152
+ </TableContext>
153
+ );
154
+ }
155
+
156
+ export type TableHeadProps = ComponentProps<"thead">;
157
+
158
+ export function TableHead(props: TableHeadProps) {
159
+ const { headerVariant } = useContext(TableContext);
160
+ return (
161
+ <SectionContext value="head">
162
+ <thead
163
+ // 反转的标题带整个换成反转主题:列头的焦点环、排序的三角、
164
+ // 放在里面的复选框都按这条带子的底色取值
165
+ data-theme={headerVariant === "band" ? "inverse" : undefined}
166
+ {...props}
167
+ />
168
+ </SectionContext>
169
+ );
170
+ }
171
+
172
+ export type TableBodyProps = ComponentProps<"tbody">;
173
+
174
+ /*
175
+ * 每隔五行加重一条线。只数主行:展开的明细也是一个 <tr>,不能让它把数数错。
176
+ * 第五行自己展开着的时候,它的下边线没有了,加重的那条画在它的明细下面
177
+ */
178
+ const ruledLines = [
179
+ "[&>tr:nth-child(5n_of_:not([data-detail])):not([data-expanded])>*]:border-line-strong",
180
+ "[&>tr:nth-child(5n_of_:not([data-detail]))+tr[data-detail]>*]:border-line-strong",
181
+ ].join(" ");
182
+
183
+ export function TableBody({ className, ...props }: TableBodyProps) {
184
+ const { ruled } = useContext(TableContext);
185
+ return (
186
+ <SectionContext value="body">
187
+ <tbody {...props} className={cn(ruled && ruledLines, className)} />
188
+ </SectionContext>
189
+ );
190
+ }
191
+
192
+ export type TableRowProps = ComponentProps<"tr"> & {
193
+ /** 选中:浅灰底 + 左缘黄条,输出 `aria-selected`。只用在表身的行上 */
194
+ selected?: boolean;
195
+ /**
196
+ * 这一行的明细。传了这一行就能展开:展开时它出现在这一行下面,通栏。
197
+ * 展开钮自己放:在某一格里放一个 `TableExpander`
198
+ */
199
+ detail?: ReactNode;
200
+ /** 受控的展开状态 */
201
+ expanded?: boolean;
202
+ /** 非受控时一开始是不是展开的 */
203
+ defaultExpanded?: boolean;
204
+ onExpandedChange?: (expanded: boolean) => void;
205
+ };
206
+
207
+ /*
208
+ * 行的底色同时写进 --row-bg:冻结的那一格不能透明(滚过去的内容会透出来),
209
+ * 它拿这个变量当底。悬停的那一档是"墨色 5% 混进表格的底",
210
+ * 看上去和其余单元格上那层半透明的悬停底一样。
211
+ */
212
+ const rowIdle = [
213
+ "[--row-bg:var(--table-surface)] hover:bg-ink/5",
214
+ "hover:[--row-bg:color-mix(in_srgb,var(--ef-ink)_5%,var(--table-surface))]",
215
+ ].join(" ");
216
+
217
+ const rowSelected = [
218
+ "bg-surface-muted [--row-bg:var(--ef-surface-muted)]",
219
+ "[&>*:first-child]:shadow-[inset_4px_0_0_var(--ef-action)]",
220
+ ].join(" ");
221
+
222
+ export function TableRow({
223
+ selected,
224
+ detail,
225
+ expanded: expandedProp,
226
+ defaultExpanded = false,
227
+ onExpandedChange,
228
+ className,
229
+ ref,
230
+ children,
231
+ ...props
232
+ }: TableRowProps) {
233
+ const section = useContext(SectionContext);
234
+ const rowRef = useRef<HTMLTableRowElement>(null);
235
+ const detailId = useId();
236
+ const [expanded, setExpanded] = useControllableState({
237
+ value: expandedProp,
238
+ defaultValue: defaultExpanded,
239
+ onChange: onExpandedChange,
240
+ });
241
+ const expandable =
242
+ detail !== undefined && detail !== null && detail !== false;
243
+ const open = expandable && expanded;
244
+
245
+ const context = useMemo(
246
+ () =>
247
+ expandable
248
+ ? { expanded: open, toggle: () => setExpanded(!open), detailId }
249
+ : null,
250
+ [expandable, open, setExpanded, detailId],
251
+ );
252
+
253
+ if (section === "head") {
254
+ return (
255
+ <tr {...props} ref={ref} className={className}>
256
+ {children}
257
+ </tr>
258
+ );
259
+ }
260
+
261
+ return (
262
+ <>
263
+ <tr
264
+ {...props}
265
+ ref={mergeRefs(rowRef, ref)}
266
+ aria-selected={selected}
267
+ data-selected={selected ? "" : undefined}
268
+ data-expanded={open ? "" : undefined}
269
+ className={cn(
270
+ "group/row transition-colors duration-(--duration-fast) ease-standard",
271
+ selected ? rowSelected : rowIdle,
272
+ // 展开着:它和下面的明细是一块,中间不画线
273
+ "[&[data-expanded]>*]:border-b-transparent",
274
+ className,
275
+ )}
276
+ >
277
+ <RowContext value={context}>{children}</RowContext>
278
+ </tr>
279
+ {open && (
280
+ <DetailRow
281
+ id={detailId}
282
+ rowRef={rowRef}
283
+ estimate={countCells(children)}
284
+ >
285
+ {detail}
286
+ </DetailRow>
287
+ )}
288
+ </>
289
+ );
290
+ }
291
+
292
+ /** 这一行占了几列:按子元素估。包了一层的、条件渲染的估不准,挂上之后再按 DOM 校正 */
293
+ function countCells(children: ReactNode) {
294
+ let total = 0;
295
+ for (const child of Children.toArray(children)) {
296
+ if (!isValidElement<{ colSpan?: number }>(child)) continue;
297
+ total += Number(child.props.colSpan ?? 1);
298
+ }
299
+ return Math.max(total, 1);
300
+ }
301
+
302
+ function DetailRow({
303
+ id,
304
+ rowRef,
305
+ estimate,
306
+ children,
307
+ }: {
308
+ id: string;
309
+ rowRef: RefObject<HTMLTableRowElement | null>;
310
+ estimate: number;
311
+ children: ReactNode;
312
+ }) {
313
+ const [measured, setMeasured] = useState<number | null>(null);
314
+
315
+ // 每次渲染都重新数:列是使用方的,随时可能多一列少一列
316
+ useLayoutEffect(() => {
317
+ const row = rowRef.current;
318
+ if (!row) return;
319
+ let total = 0;
320
+ for (const cell of row.cells) total += cell.colSpan;
321
+ if (total > 0) setMeasured(total);
322
+ });
323
+
324
+ return (
325
+ <tr id={id} data-detail="">
326
+ <td
327
+ colSpan={measured ?? estimate}
328
+ className="border-b border-line bg-surface-sunken p-0 [tr:last-child>&]:border-b-0"
329
+ >
330
+ {/* 表格横向滚动时明细不跟着滚走:钉在容器的左缘,宽度是容器看得见的那一段 */}
331
+ <div className="sticky left-0 w-[var(--table-viewport,100%)] px-4 py-3 text-sm whitespace-normal">
332
+ {children}
333
+ </div>
334
+ </td>
335
+ </tr>
336
+ );
337
+ }
338
+
339
+ export type TableExpanderProps = Omit<
340
+ ComponentProps<"button">,
341
+ "aria-expanded" | "aria-controls" | "children"
342
+ >;
343
+
344
+ /**
345
+ * 展开钮:放在能展开的行(传了 `detail` 的 `TableRow`)的某一格里,通常在名称前面。
346
+ * 一张表里有很多个,名称写成"TR-2041 的明细"读屏才分得清。放在不能展开的行里不渲染。
347
+ */
348
+ export function TableExpander({
349
+ "aria-label": label = "明细",
350
+ type = "button",
351
+ className,
352
+ onClick,
353
+ ...props
354
+ }: TableExpanderProps) {
355
+ const row = useContext(RowContext);
356
+ if (!row) return null;
357
+
358
+ return (
359
+ <button
360
+ {...props}
361
+ type={type}
362
+ aria-label={label}
363
+ aria-expanded={row.expanded}
364
+ aria-controls={row.expanded ? row.detailId : undefined}
365
+ onClick={(event: MouseEvent<HTMLButtonElement>) => {
366
+ onClick?.(event);
367
+ if (!event.defaultPrevented) row.toggle();
368
+ }}
369
+ className={cn(
370
+ "relative inline-flex size-6 shrink-0 items-center justify-center align-middle text-ink-secondary",
371
+ // 24px 小于触屏的最小点击区,用伪元素向外补到 40px
372
+ "after:absolute after:-inset-2 after:content-['']",
373
+ "transition-colors duration-(--duration-fast) ease-standard",
374
+ "hover:bg-ink/5 hover:text-ink",
375
+ focusRing,
376
+ className,
377
+ )}
378
+ >
379
+ <TriangleRight
380
+ size={8}
381
+ className={cn(
382
+ "transition-transform duration-(--duration-fast) ease-standard",
383
+ row.expanded && "rotate-90",
384
+ )}
385
+ />
386
+ </button>
387
+ );
388
+ }
389
+
390
+ const alignClass: Record<TableAlign, string> = {
391
+ start: "text-left",
392
+ center: "text-center",
393
+ end: "text-right",
394
+ };
395
+
396
+ const cellPadding = "px-3 first:pl-4 last:pr-4";
397
+
398
+ /* 冻结的第一列 */
399
+ const stickyCell = "first:sticky first:left-0 first:z-1";
400
+
401
+ /*
402
+ * 吸顶的表头。左上角那一格同时属于冻结的列和吸顶的行,要压在两者之上:
403
+ * 表身的冻结格在文档里排在后面,层级相同时会盖住它
404
+ */
405
+ const stickyHead = "sticky top-0 z-1";
406
+ const stickyCorner = "first:z-2";
407
+
408
+ /* `band` 的 surface 是反转主题里的 surface,也就是页面的反转色 */
409
+ const headerFill: Record<TableHeaderVariant, string> = {
410
+ band: "bg-surface text-ink",
411
+ muted: "bg-surface-muted text-ink",
412
+ };
413
+
414
+ export type TableHeaderCellProps = Omit<ComponentProps<"th">, "align"> & {
415
+ /** 数字列:右对齐 */
416
+ numeric?: boolean;
417
+ /** 对齐,默认靠左;`numeric` 时默认靠右 */
418
+ align?: TableAlign;
419
+ /** 这一列现在的排序方向。没按它排就不传 */
420
+ sort?: TableSortDirection | null;
421
+ /**
422
+ * 传了这一列就能排序:列名变成一个按钮,右侧一个小三角。
423
+ * 拿到的是点了之后该换成的方向——没排或降序时是升序,升序时是降序
424
+ */
425
+ onSort?: (next: TableSortDirection) => void;
426
+ };
427
+
428
+ /** 列头:`<th scope="col">`。 */
429
+ export function TableHeaderCell({
430
+ numeric = false,
431
+ align = numeric ? "end" : "start",
432
+ sort,
433
+ onSort,
434
+ className,
435
+ children,
436
+ ...props
437
+ }: TableHeaderCellProps) {
438
+ const { headerVariant, sticky, stickyHeader } = useContext(TableContext);
439
+
440
+ return (
441
+ <th
442
+ scope="col"
443
+ {...props}
444
+ aria-sort={sort ?? undefined}
445
+ className={cn(
446
+ "h-8 font-medium whitespace-nowrap",
447
+ headerFill[headerVariant],
448
+ alignClass[align],
449
+ // 能排序的列,内边距让给里面的按钮:整格都能点
450
+ onSort ? "p-0" : cellPadding,
451
+ sticky && stickyCell,
452
+ stickyHeader && [stickyHead, sticky && stickyCorner],
453
+ className,
454
+ )}
455
+ >
456
+ {onSort ? (
457
+ <button
458
+ type="button"
459
+ onClick={() =>
460
+ onSort(sort === "ascending" ? "descending" : "ascending")
461
+ }
462
+ className={cn(
463
+ "group/sort flex h-8 w-full items-center gap-1.5 font-medium",
464
+ // 数字列的三角放在左边:列名的右缘才能和下面的数字对齐
465
+ align === "end" && "flex-row-reverse",
466
+ align === "center" && "justify-center",
467
+ // th 的 first: / last: 落不到按钮上,这里照着单元格的内边距再写一遍
468
+ "px-3 [th:first-child>&]:pl-4 [th:last-child>&]:pr-4",
469
+ focusRingInset,
470
+ )}
471
+ >
472
+ {children}
473
+ <TriangleRight
474
+ size={8}
475
+ className={cn(
476
+ "shrink-0 transition-[opacity,rotate] duration-(--duration-fast) ease-standard",
477
+ sort === "ascending" ? "-rotate-90" : "rotate-90",
478
+ sort
479
+ ? "text-accent-ink"
480
+ : "opacity-40 group-hover/sort:opacity-100",
481
+ )}
482
+ />
483
+ </button>
484
+ ) : (
485
+ children
486
+ )}
487
+ </th>
488
+ );
489
+ }
490
+
491
+ const cellHeight: Record<TableSize, string> = {
492
+ sm: "h-9",
493
+ md: "h-11",
494
+ };
495
+
496
+ export type TableCellProps = Omit<ComponentProps<"td">, "align"> & {
497
+ /** 数字:右对齐、等宽 */
498
+ numeric?: boolean;
499
+ /** 对齐,默认靠左;`numeric` 时默认靠右 */
500
+ align?: TableAlign;
501
+ /** 这一格是这一行的名称:渲染成 `<th scope="row">` */
502
+ rowHeader?: boolean;
503
+ /**
504
+ * 行内操作:鼠标悬停或键盘聚焦到这一行时才显示。
505
+ * 触屏上没有悬停,所以常显
506
+ */
507
+ reveal?: boolean;
508
+ };
509
+
510
+ /** 单元格:`<td>`;`rowHeader` 时是 `<th scope="row">`。 */
511
+ export function TableCell({
512
+ numeric = false,
513
+ align = numeric ? "end" : "start",
514
+ rowHeader = false,
515
+ reveal = false,
516
+ className,
517
+ children,
518
+ ...props
519
+ }: TableCellProps) {
520
+ const { size, sticky } = useContext(TableContext);
521
+ const Cell = rowHeader ? "th" : "td";
522
+
523
+ return (
524
+ <Cell
525
+ {...(rowHeader && { scope: "row" })}
526
+ {...props}
527
+ className={cn(
528
+ // 默认不折行:放不下就让表格横向滚动。要折行的长文字列自己加 whitespace-normal
529
+ "border-b border-line py-1 whitespace-nowrap [tr:last-child>&]:border-b-0",
530
+ cellHeight[size],
531
+ cellPadding,
532
+ alignClass[align],
533
+ numeric && "font-tech tabular-nums",
534
+ rowHeader && "font-medium",
535
+ sticky && [
536
+ stickyCell,
537
+ "first:bg-(--row-bg)",
538
+ // 溢出的时候右缘多一条线,看得出它下面压着东西
539
+ "group-data-overflowing/table:first:border-r",
540
+ ],
541
+ className,
542
+ )}
543
+ >
544
+ {reveal ? (
545
+ <div
546
+ className={cn(
547
+ "inline-flex items-center gap-1 align-middle transition-opacity duration-(--duration-fast) ease-standard",
548
+ // 有鼠标的设备上平时藏起来;这一行被悬停、或者焦点进了这一行就显示
549
+ "[@media(hover:hover)]:opacity-0 group-focus-within/row:opacity-100 group-hover/row:opacity-100",
550
+ )}
551
+ >
552
+ {children}
553
+ </div>
554
+ ) : (
555
+ children
556
+ )}
557
+ </Cell>
558
+ );
559
+ }
@@ -0,0 +1,20 @@
1
+ export {
2
+ Table,
3
+ TableBody,
4
+ TableCell,
5
+ TableExpander,
6
+ TableHead,
7
+ TableHeaderCell,
8
+ TableRow,
9
+ type TableAlign,
10
+ type TableBodyProps,
11
+ type TableCellProps,
12
+ type TableExpanderProps,
13
+ type TableHeadProps,
14
+ type TableHeaderCellProps,
15
+ type TableHeaderVariant,
16
+ type TableProps,
17
+ type TableRowProps,
18
+ type TableSize,
19
+ type TableSortDirection,
20
+ } from "./Table";