@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,109 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ import { StatusDanger } from "../../icons/StatusDanger";
3
+ import { cn } from "../../lib/cn";
4
+ import { useFieldControl } from "../field/Field";
5
+ import {
6
+ controlBox,
7
+ controlElement,
8
+ controlIconSize,
9
+ controlSize,
10
+ type ControlSize,
11
+ type ControlVariant,
12
+ } from "./control-box";
13
+
14
+ export type InputSize = ControlSize;
15
+ export type InputVariant = ControlVariant;
16
+
17
+ export type InputProps = Omit<ComponentProps<"input">, "size"> & {
18
+ /**
19
+ * - `sunken` 凹陷的底 + 一条底边线,默认;
20
+ * - `outline` 四边描边,放在凹陷底色的区域(工具条、筛选栏)里时用。
21
+ */
22
+ variant?: InputVariant;
23
+ /** 32 / 40 / 56px 高 */
24
+ size?: InputSize;
25
+ /** 框内左端:图标 */
26
+ start?: ReactNode;
27
+ /** 框内右端:单位、清除按钮 */
28
+ end?: ReactNode;
29
+ /** 错误态:底边线变红,右侧出现菱形图标。放在 `Field` 里时跟随它的 `error` */
30
+ invalid?: boolean;
31
+ };
32
+
33
+ /**
34
+ * 单行输入框。`className` 给外框,其余属性与 `ref` 给里面的 `<input>`。
35
+ * 每个输入框都要有标签:放进 `Field`,或者自己传 `aria-label`。
36
+ */
37
+ export function Input({
38
+ variant = "sunken",
39
+ size = "md",
40
+ start,
41
+ end,
42
+ invalid: invalidProp,
43
+ id: idProp,
44
+ disabled: disabledProp,
45
+ required: requiredProp,
46
+ readOnly = false,
47
+ "aria-describedby": describedByProp,
48
+ className,
49
+ ...props
50
+ }: InputProps) {
51
+ const { id, disabled, required, invalid, ...field } = useFieldControl({
52
+ id: idProp,
53
+ disabled: disabledProp,
54
+ required: requiredProp,
55
+ invalid: invalidProp,
56
+ "aria-describedby": describedByProp,
57
+ });
58
+
59
+ return (
60
+ <div
61
+ data-variant={variant}
62
+ data-size={size}
63
+ data-invalid={invalid ? "" : undefined}
64
+ className={cn(
65
+ controlBox({ variant, invalid, disabled, readOnly }),
66
+ "items-center",
67
+ controlSize[size],
68
+ className,
69
+ )}
70
+ >
71
+ {start && (
72
+ <span className="flex shrink-0 items-center pl-3 text-ink-secondary">
73
+ {start}
74
+ </span>
75
+ )}
76
+ <input
77
+ {...props}
78
+ id={id}
79
+ disabled={disabled}
80
+ required={required}
81
+ readOnly={readOnly}
82
+ aria-invalid={invalid || undefined}
83
+ aria-describedby={field["aria-describedby"]}
84
+ className={cn(
85
+ controlElement,
86
+ "h-full",
87
+ start ? "pl-2" : "pl-3",
88
+ end || invalid ? "pr-2" : "pr-3",
89
+ )}
90
+ />
91
+ {invalid && (
92
+ <StatusDanger
93
+ size={controlIconSize[size]}
94
+ className={cn("shrink-0 text-danger", !end && "mr-3")}
95
+ />
96
+ )}
97
+ {end && (
98
+ <span
99
+ className={cn(
100
+ "flex shrink-0 items-center pr-3 text-sm text-ink-secondary",
101
+ invalid && "pl-2",
102
+ )}
103
+ >
104
+ {end}
105
+ </span>
106
+ )}
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,71 @@
1
+ import { cn } from "../../lib/cn";
2
+ import { focusRingWithin } from "../../lib/focus-ring";
3
+
4
+ /*
5
+ * 输入框与多行文本共用的外框,直角,两种画法:
6
+ * - `sunken` 凹陷的底 + 一条 2px 的底边线,默认;
7
+ * - `outline` 四边 1px 的描边 + 页面底色,放在凹陷底色的区域里时用。
8
+ * 状态都靠边线的颜色变化:默认灰 → 悬停深一档 → 聚焦墨 → 错误红。
9
+ */
10
+
11
+ export type ControlVariant = "sunken" | "outline";
12
+ export type ControlSize = "sm" | "md" | "lg";
13
+
14
+ /** 32 / 40 / 56px 高,字号跟随通用的三档。输入框和下拉选择的触发器共用 */
15
+ export const controlSize: Record<ControlSize, string> = {
16
+ sm: "h-8 text-sm",
17
+ md: "h-10 text-base",
18
+ lg: "h-14 text-lg",
19
+ };
20
+
21
+ export const controlIconSize: Record<ControlSize, number> = {
22
+ sm: 14,
23
+ md: 16,
24
+ lg: 20,
25
+ };
26
+
27
+ type BoxState = {
28
+ variant?: ControlVariant;
29
+ invalid: boolean;
30
+ disabled: boolean;
31
+ readOnly: boolean;
32
+ };
33
+
34
+ const frame: Record<ControlVariant, string> = {
35
+ sunken: "border-b-2",
36
+ outline: "border",
37
+ };
38
+
39
+ const fill: Record<ControlVariant, string> = {
40
+ sunken: "bg-surface-sunken",
41
+ outline: "bg-surface",
42
+ };
43
+
44
+ export function controlBox({
45
+ variant = "sunken",
46
+ invalid,
47
+ disabled,
48
+ readOnly,
49
+ }: BoxState) {
50
+ return cn(
51
+ "flex w-full min-w-0 text-ink",
52
+ frame[variant],
53
+ "transition-colors duration-(--duration-fast) ease-standard",
54
+ focusRingWithin,
55
+ readOnly
56
+ ? // 只读:没有边线、底变透明。边线留透明的,尺寸不跳
57
+ "border-transparent bg-transparent"
58
+ : fill[variant],
59
+ disabled
60
+ ? "cursor-not-allowed border-line text-ink-disabled"
61
+ : invalid
62
+ ? "border-danger"
63
+ : !readOnly &&
64
+ // 悬停只在没聚焦时生效:生成的 CSS 里 hover 排在 focus-within 之后,会盖掉聚焦的墨色
65
+ "border-line-strong hover:not-focus-within:border-ink-secondary focus-within:border-ink",
66
+ );
67
+ }
68
+
69
+ /** 框里真正的 `<input>` / `<textarea>`:没有自己的底和边,焦点环画在外框上 */
70
+ export const controlElement =
71
+ "min-w-0 flex-1 bg-transparent outline-none placeholder:text-ink-tertiary disabled:cursor-not-allowed disabled:placeholder:text-ink-disabled";
@@ -0,0 +1,6 @@
1
+ export {
2
+ Input,
3
+ type InputProps,
4
+ type InputSize,
5
+ type InputVariant,
6
+ } from "./Input";
@@ -0,0 +1,34 @@
1
+ import type { ControlSize } from "./control-box";
2
+
3
+ /*
4
+ * 已经定下来的值在框里排成的小块:组合框的多选、标签输入共用。
5
+ * 直角——它是值,不是会自己变的状态。右端一个删除叉。
6
+ */
7
+
8
+ /** 小块高 20 / 24 / 32px,跟着外框的三档 */
9
+ export const valueChipSize: Record<ControlSize, string> = {
10
+ sm: "h-5 text-xs",
11
+ md: "h-6 text-sm",
12
+ lg: "h-8 text-base",
13
+ };
14
+
15
+ export const valueChip =
16
+ "flex max-w-full min-w-0 cursor-default items-center bg-surface-muted pl-2 text-ink";
17
+
18
+ /** 小块右端的删除叉:一个和小块等高的方钮 */
19
+ export const valueChipRemove =
20
+ "flex aspect-square h-full shrink-0 items-center justify-center text-ink-secondary hover:bg-line hover:text-ink";
21
+
22
+ /** 和小块排在一起的那个输入框:和小块一样高,这一行才不会比小块高出一截 */
23
+ export const valueInputHeight: Record<ControlSize, string> = {
24
+ sm: "h-5",
25
+ md: "h-6",
26
+ lg: "h-8",
27
+ };
28
+
29
+ /** 多选时外框只定最小高度:小块多了会换行、外框跟着长高 */
30
+ export const multiValueBox: Record<ControlSize, string> = {
31
+ sm: "min-h-8 text-sm",
32
+ md: "min-h-10 text-base",
33
+ lg: "min-h-14 text-lg",
34
+ };
@@ -0,0 +1,207 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ type ComponentProps,
5
+ type FocusEvent,
6
+ type KeyboardEvent,
7
+ } from "react";
8
+ import { cn } from "../../lib/cn";
9
+ import { mergeRefs } from "../../lib/merge-refs";
10
+
11
+ export type ItemGridProps = ComponentProps<"div">;
12
+
13
+ type SlotPosition = { top: number; left: number };
14
+
15
+ const CONTROL = "[data-slot-control]";
16
+ /** 禁用的按钮本来就聚焦不了;禁用的链接只是标了 `aria-disabled` */
17
+ const ENABLED = `${CONTROL}:not(:disabled):not([aria-disabled="true"])`;
18
+ const SELECTED = '[aria-pressed="true"], [aria-current]';
19
+
20
+ const KEYS = new Set([
21
+ "ArrowLeft",
22
+ "ArrowRight",
23
+ "ArrowUp",
24
+ "ArrowDown",
25
+ "Home",
26
+ "End",
27
+ ]);
28
+
29
+ /** 同一行的格子顶边应该一样高,留 2px 给小数像素 */
30
+ const ROW_TOLERANCE = 2;
31
+
32
+ /**
33
+ * 按下一个键之后该到哪一格。`positions` 是各格子的位置(文档顺序),返回目标的下标;
34
+ * 不该动的时候(已经在边上、不认识的键)返回 `current`。
35
+ *
36
+ * - `←` `→` 在同一行里移动,到头不折行;
37
+ * - `↑` `↓` 到上下一行里水平位置最近的那一格;
38
+ * - `Home` / `End` 到行首行尾,带 `ctrl` 是第一格和最后一格。
39
+ */
40
+ export function nextSlotIndex(
41
+ positions: readonly SlotPosition[],
42
+ current: number,
43
+ key: string,
44
+ ctrl = false,
45
+ ): number {
46
+ const here = positions[current];
47
+ if (!here) return current;
48
+
49
+ // 按顶边把格子分成行;行内按左边排,左边一样时按文档顺序
50
+ const order = positions
51
+ .map((_, index) => index)
52
+ .sort(
53
+ (a, b) =>
54
+ positions[a]!.top - positions[b]!.top ||
55
+ positions[a]!.left - positions[b]!.left ||
56
+ a - b,
57
+ );
58
+ const rows: number[][] = [];
59
+ for (const index of order) {
60
+ const row = rows.at(-1);
61
+ if (
62
+ row &&
63
+ positions[index]!.top - positions[row[0]!]!.top <= ROW_TOLERANCE
64
+ ) {
65
+ row.push(index);
66
+ } else {
67
+ rows.push([index]);
68
+ }
69
+ }
70
+ for (const row of rows) {
71
+ row.sort((a, b) => positions[a]!.left - positions[b]!.left || a - b);
72
+ }
73
+
74
+ const rowIndex = rows.findIndex((row) => row.includes(current));
75
+ const row = rows[rowIndex]!;
76
+ const column = row.indexOf(current);
77
+
78
+ const nearestIn = (target: number[] | undefined) => {
79
+ if (!target) return current;
80
+ let best = target[0]!;
81
+ for (const index of target) {
82
+ if (
83
+ Math.abs(positions[index]!.left - here.left) <
84
+ Math.abs(positions[best]!.left - here.left)
85
+ ) {
86
+ best = index;
87
+ }
88
+ }
89
+ return best;
90
+ };
91
+
92
+ switch (key) {
93
+ case "ArrowRight":
94
+ return row[column + 1] ?? current;
95
+ case "ArrowLeft":
96
+ return row[column - 1] ?? current;
97
+ case "ArrowDown":
98
+ return nearestIn(rows[rowIndex + 1]);
99
+ case "ArrowUp":
100
+ return nearestIn(rows[rowIndex - 1]);
101
+ case "Home":
102
+ return ctrl ? rows[0]![0]! : row[0]!;
103
+ case "End":
104
+ return ctrl ? rows.at(-1)!.at(-1)! : row.at(-1)!;
105
+ default:
106
+ return current;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 物品格的矩阵:一组 `ItemSlot` 排成自动填充的网格,整个矩阵只占一个 Tab 停靠点,
112
+ * 进去之后用方向键在格子之间走。给它一个 `aria-label`,说明这是哪一组物品。
113
+ *
114
+ * 默认每格最小 72px、间距 8px,四周留 4px 给选中格的角括号;用 `className` 改。
115
+ */
116
+ export function ItemGrid({
117
+ className,
118
+ onKeyDown,
119
+ onFocus,
120
+ ref: forwardedRef,
121
+ ...props
122
+ }: ItemGridProps) {
123
+ const ref = useRef<HTMLDivElement>(null);
124
+
125
+ // 漫游 tabindex:只有一格能被 Tab 停到。优先是上次聚焦的那一格,其次是选中的,再其次是第一格。
126
+ // 直接写在 DOM 上——格子自己不管 tabindex,React 不会把它改回去
127
+ useEffect(() => {
128
+ const grid = ref.current;
129
+ if (!grid) return;
130
+
131
+ const sync = () => {
132
+ const enabled = [...grid.querySelectorAll<HTMLElement>(ENABLED)];
133
+ const stop =
134
+ enabled.find((control) => control === document.activeElement) ??
135
+ enabled.find((control) => control.getAttribute("tabindex") === "0") ??
136
+ enabled.find((control) => control.matches(SELECTED)) ??
137
+ enabled[0];
138
+ for (const control of grid.querySelectorAll<HTMLElement>(CONTROL)) {
139
+ const next = control === stop ? "0" : "-1";
140
+ if (control.getAttribute("tabindex") !== next) {
141
+ control.setAttribute("tabindex", next);
142
+ }
143
+ }
144
+ };
145
+
146
+ sync();
147
+ // 格子增减、禁用状态变化之后重新定一次;只盯这些,自己改 tabindex 不会再触发
148
+ const observer = new MutationObserver(sync);
149
+ observer.observe(grid, {
150
+ childList: true,
151
+ subtree: true,
152
+ attributes: true,
153
+ attributeFilter: ["disabled", "aria-disabled"],
154
+ });
155
+ return () => observer.disconnect();
156
+ }, []);
157
+
158
+ const handleFocus = (event: FocusEvent<HTMLDivElement>) => {
159
+ onFocus?.(event);
160
+ const target = event.target as HTMLElement;
161
+ if (!target.matches(CONTROL)) return;
162
+ // 焦点到了哪一格(方向键走过去的,或者点的),停靠点就跟到哪一格
163
+ for (const control of event.currentTarget.querySelectorAll(CONTROL)) {
164
+ control.setAttribute("tabindex", control === target ? "0" : "-1");
165
+ }
166
+ };
167
+
168
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
169
+ onKeyDown?.(event);
170
+ if (event.defaultPrevented || !KEYS.has(event.key)) return;
171
+ if (event.altKey || event.metaKey || event.shiftKey) return;
172
+ const ctrl = event.ctrlKey;
173
+ // Ctrl 只和 Home / End 配
174
+ if (ctrl && event.key !== "Home" && event.key !== "End") return;
175
+
176
+ const controls = [
177
+ ...event.currentTarget.querySelectorAll<HTMLElement>(ENABLED),
178
+ ];
179
+ const current = controls.indexOf(document.activeElement as HTMLElement);
180
+ if (current === -1) return;
181
+
182
+ // 网格是自动填充的,一行几格随宽度变:按格子此刻的实际位置算
183
+ const positions = controls.map((control) => {
184
+ const rect = control.getBoundingClientRect();
185
+ return { top: rect.top, left: rect.left };
186
+ });
187
+ const next = nextSlotIndex(positions, current, event.key, ctrl);
188
+
189
+ // 走到边上不动的时候也拦下来,免得页面跟着滚
190
+ event.preventDefault();
191
+ if (next !== current) controls[next]!.focus();
192
+ };
193
+
194
+ return (
195
+ <div
196
+ role="group"
197
+ {...props}
198
+ ref={mergeRefs(ref, forwardedRef)}
199
+ onFocus={handleFocus}
200
+ onKeyDown={handleKeyDown}
201
+ className={cn(
202
+ "grid grid-cols-[repeat(auto-fill,minmax(4.5rem,1fr))] gap-2 p-1",
203
+ className,
204
+ )}
205
+ />
206
+ );
207
+ }