@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,35 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { decor } from "../../lib/decor";
4
+
5
+ export type HazardStripeSize = "sm" | "md";
6
+
7
+ export type HazardStripeProps = Omit<ComponentProps<"div">, "children"> & {
8
+ /** 条高 6px 或 12px */
9
+ size?: HazardStripeSize;
10
+ };
11
+
12
+ const sizeClass: Record<HazardStripeSize, string> = {
13
+ sm: "h-1.5",
14
+ md: "h-3",
15
+ };
16
+
17
+ /**
18
+ * 警示条纹:黄黑相间的窄条,和红色一样带"危险 / 施工"的含义。
19
+ * 放在删除确认、不可逆操作这类区域的边缘;一个视口最多一处,普通内容不要用。
20
+ * 它只是装饰:危险的含义必须另外用文字写出来。
21
+ */
22
+ export function HazardStripe({
23
+ size = "md",
24
+ className,
25
+ ...props
26
+ }: HazardStripeProps) {
27
+ return (
28
+ <div
29
+ {...props}
30
+ aria-hidden="true"
31
+ // 有意不随主题变:警示条纹在哪种底色上都是黄黑两色
32
+ className={cn(decor, "hazard w-full", sizeClass[size], className)}
33
+ />
34
+ );
35
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ HazardStripe,
3
+ type HazardStripeProps,
4
+ type HazardStripeSize,
5
+ } from "./HazardStripe";
@@ -0,0 +1,95 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { focusRing } from "../../lib/focus-ring";
4
+
5
+ export type IconButtonVariant = "plain" | "floating" | "accent" | "inverse";
6
+ export type IconButtonSize = "sm" | "md" | "lg";
7
+
8
+ export type IconButtonProps = Omit<ComponentProps<"button">, "aria-label"> & {
9
+ /** 纯图标控件必须有可访问名称 */
10
+ "aria-label": string;
11
+ /**
12
+ * - `plain` 方形浅灰底,工具栏与顶栏,默认;
13
+ * - `floating` 圆形白底 + 环境阴影,翻页与浮在图像上的操作;
14
+ * - `accent` 方形,悬停或激活时变信号黄;
15
+ * - `inverse` 方形,悬停或激活时变墨底;它打开的菜单、展开条开着的时候也是。
16
+ */
17
+ variant?: IconButtonVariant;
18
+ size?: IconButtonSize;
19
+ /** 开关类按钮的激活状态,同时输出 `aria-pressed` */
20
+ pressed?: boolean;
21
+ };
22
+
23
+ const sizeClass: Record<IconButtonSize, string> = {
24
+ // 32px 小于触屏的最小点击区,用伪元素向外补到 40px
25
+ sm: "size-8 after:absolute after:-inset-1 after:content-[''] [&_svg]:size-4",
26
+ md: "size-10 [&_svg]:size-5",
27
+ lg: "size-12 [&_svg]:size-6",
28
+ };
29
+
30
+ const restClass =
31
+ "bg-surface-sunken text-ink-secondary hover:bg-surface-muted hover:text-ink active:bg-line";
32
+
33
+ const enabledClass: Record<IconButtonVariant, string> = {
34
+ plain: restClass,
35
+ // 有意不随主题翻转:它要压在图像和深色版块上
36
+ floating:
37
+ "rounded-full bg-neutral-50 text-neutral-900 shadow-sm hover:bg-neutral-200 active:bg-neutral-300",
38
+ accent:
39
+ "bg-surface-sunken text-ink-secondary hover:bg-action hover:text-on-action active:bg-action-pressed",
40
+ inverse:
41
+ // data-popup-open 是浮层基元写在触发元素上的:它打开的东西还开着
42
+ "bg-surface-sunken text-ink-secondary hover:bg-surface-inverse hover:text-accent-ink-inverse active:bg-surface-inverse data-popup-open:bg-surface-inverse data-popup-open:text-accent-ink-inverse",
43
+ };
44
+
45
+ const pressedClass: Record<IconButtonVariant, string> = {
46
+ plain: "bg-surface-muted text-ink active:bg-line",
47
+ floating:
48
+ "rounded-full bg-neutral-200 text-neutral-900 shadow-sm active:bg-neutral-300",
49
+ accent: "bg-action text-on-action active:bg-action-pressed",
50
+ inverse: "bg-surface-inverse text-accent-ink-inverse",
51
+ };
52
+
53
+ const disabledClass: Record<IconButtonVariant, string> = {
54
+ plain: "cursor-not-allowed bg-surface-sunken text-ink-disabled",
55
+ floating:
56
+ "cursor-not-allowed rounded-full bg-neutral-50 text-neutral-400 shadow-sm",
57
+ accent: "cursor-not-allowed bg-surface-sunken text-ink-disabled",
58
+ inverse: "cursor-not-allowed bg-surface-sunken text-ink-disabled",
59
+ };
60
+
61
+ export function IconButton({
62
+ variant = "plain",
63
+ size = "md",
64
+ pressed,
65
+ disabled = false,
66
+ type = "button",
67
+ className,
68
+ children,
69
+ ...props
70
+ }: IconButtonProps) {
71
+ return (
72
+ <button
73
+ {...props}
74
+ type={type}
75
+ disabled={disabled}
76
+ aria-pressed={pressed}
77
+ data-variant={variant}
78
+ data-size={size}
79
+ className={cn(
80
+ "relative inline-flex shrink-0 items-center justify-center",
81
+ "transition-colors duration-(--duration-fast) ease-standard",
82
+ focusRing,
83
+ sizeClass[size],
84
+ disabled
85
+ ? disabledClass[variant]
86
+ : pressed
87
+ ? pressedClass[variant]
88
+ : enabledClass[variant],
89
+ className,
90
+ )}
91
+ >
92
+ {children}
93
+ </button>
94
+ );
95
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ IconButton,
3
+ type IconButtonProps,
4
+ type IconButtonSize,
5
+ type IconButtonVariant,
6
+ } from "./IconButton";
@@ -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
+ }