@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,284 @@
1
+ import type { ComponentProps, MouseEvent, ReactNode } from "react";
2
+ import { Crosshair } from "../../icons/Crosshair";
3
+ import { Lock } from "../../icons/Lock";
4
+ import { cn } from "../../lib/cn";
5
+ import { decor } from "../../lib/decor";
6
+ import { focusRing } from "../../lib/focus-ring";
7
+ import { LinkElement, type LinkRender } from "../link-element/LinkElement";
8
+
9
+ export type ItemSlotRarity = 1 | 2 | 3 | 4;
10
+ export type ItemSlotRatio = "1/1" | "4/5";
11
+
12
+ type OwnProps = {
13
+ /** 物品名称。格子里只有图标,名称是给读屏的 */
14
+ name: string;
15
+ /** 完整的读屏文字。缺省由名称、数量、稀有度与各状态拼出来 */
16
+ label?: string;
17
+ /** 右下角的数量 */
18
+ count?: ReactNode;
19
+ /** 稀有度 1 – 4:底部一段向上消散的渐变,左缘另有同样个数的小菱形 */
20
+ rarity?: ItemSlotRarity;
21
+ /** 宽高比:正方形(默认)或 4:5 */
22
+ ratio?: ItemSlotRatio;
23
+ /** 选中:四角出现角括号 */
24
+ selected?: boolean;
25
+ /** 锁定:左下角一个锁,卡面不变灰 */
26
+ locked?: boolean;
27
+ /** 未获得(图鉴):内容换成准星占位符 */
28
+ unowned?: boolean;
29
+ /** 新获得:左上角的黄签 */
30
+ isNew?: boolean;
31
+ /** 黄签上的字,默认 `NEW` */
32
+ newLabel?: ReactNode;
33
+ /** 右上角的小圆位:已装备者的头像 */
34
+ badge?: ReactNode;
35
+ /** 售罄 / 不可用:整卡压一层遮罩。传文字("售罄")会写在遮罩上 */
36
+ // oxlint-disable-next-line typescript/no-redundant-type-constituents -- ReactNode 本来就含 boolean,写出来是让人一眼看到两种用法
37
+ unavailable?: boolean | ReactNode;
38
+ /** 传了就整格渲染成链接 */
39
+ href?: string;
40
+ target?: string;
41
+ rel?: string;
42
+ /** 用这个元素代替 `<a>`(路由库的链接组件)。传了就按链接处理 */
43
+ render?: LinkRender;
44
+ /** 传了(且没有 `href` 和 `render`)就整格渲染成按钮 */
45
+ onClick?: (event: MouseEvent<HTMLElement>) => void;
46
+ disabled?: boolean;
47
+ /** 图标或缩略图 */
48
+ children?: ReactNode;
49
+ };
50
+
51
+ export type ItemSlotProps = Omit<ComponentProps<"div">, keyof OwnProps> &
52
+ OwnProps;
53
+
54
+ /** 格子里那个可点击的元素带着它:`ItemGrid` 靠它找到能用方向键走到的格子 */
55
+ const slotControl = { "data-slot-control": "" };
56
+
57
+ const ratioClass: Record<ItemSlotRatio, string> = {
58
+ "1/1": "aspect-square",
59
+ "4/5": "aspect-4/5",
60
+ };
61
+
62
+ // 有意不随主题变:稀有度色阶是固定的角色色,这里只作渐变的填充
63
+ const rarityClass: Record<ItemSlotRarity, string> = {
64
+ 1: "from-rarity-1/55",
65
+ 2: "from-rarity-2/55",
66
+ 3: "from-rarity-3/55",
67
+ 4: "from-rarity-4/55",
68
+ };
69
+
70
+ /**
71
+ * 物品格:矩阵里的一个格子——图标、数量、稀有度。
72
+ * 整格只有一个可点击元素;宽度跟着所在的网格走,格子之间留 6 – 8px。
73
+ *
74
+ * `className` 与其余属性给最外层的盒子。角括号画在盒子之外 4px,
75
+ * 所以网格容器不能贴着格子裁切。一组格子放进 `ItemGrid`,就能用方向键在里面走。
76
+ */
77
+ export function ItemSlot({
78
+ name,
79
+ label,
80
+ count,
81
+ rarity,
82
+ ratio = "1/1",
83
+ selected = false,
84
+ locked = false,
85
+ unowned = false,
86
+ isNew = false,
87
+ newLabel = "NEW",
88
+ badge,
89
+ unavailable = false,
90
+ href,
91
+ target,
92
+ rel,
93
+ render,
94
+ onClick,
95
+ disabled = false,
96
+ className,
97
+ children,
98
+ ...props
99
+ }: ItemSlotProps) {
100
+ const isLink = href !== undefined || render !== undefined;
101
+ const isButton = !isLink && onClick !== undefined;
102
+ const interactive = (isLink || isButton) && !disabled;
103
+ const hasCount = count !== undefined && count !== null && count !== false;
104
+ const isUnavailable = unavailable !== false && unavailable !== null;
105
+ const unavailableText =
106
+ typeof unavailable === "boolean" ? undefined : unavailable;
107
+
108
+ // 图标、数量、角标都是给眼睛看的;读屏拿到的是这一句完整的话
109
+ const spoken =
110
+ label ??
111
+ [
112
+ name,
113
+ hasCount && typeof count !== "object" && `数量 ${count}`,
114
+ rarity && `稀有度 ${rarity}`,
115
+ isNew && "新获得",
116
+ unowned && "未获得",
117
+ locked && "已锁定",
118
+ isUnavailable &&
119
+ (typeof unavailableText === "string" ? unavailableText : "不可用"),
120
+ ]
121
+ .filter(Boolean)
122
+ .join(",");
123
+
124
+ const faceClass = cn(
125
+ // 卡面自己裁切:渐变、遮罩、角标都不许越界。焦点环画在它外面,不受影响
126
+ "absolute inset-0 block overflow-clip border border-line bg-surface-raised text-left text-ink",
127
+ interactive && [
128
+ "transition-colors duration-(--duration-fast) ease-standard hover:border-line-strong",
129
+ focusRing,
130
+ // 选中时让到角括号之外,两圈不重叠
131
+ selected && "focus-visible:outline-offset-[6px]",
132
+ ],
133
+ disabled && "cursor-not-allowed",
134
+ // 高对比模式下背景画的角括号不显示,换成一圈系统色的描边
135
+ selected && "forced-colors:border-2 forced-colors:border-[Highlight]",
136
+ );
137
+
138
+ const face = (
139
+ <>
140
+ {rarity && (
141
+ <span
142
+ aria-hidden="true"
143
+ className={cn(
144
+ decor,
145
+ "absolute inset-x-0 bottom-0 h-2/5 bg-linear-to-t to-transparent",
146
+ rarityClass[rarity],
147
+ )}
148
+ />
149
+ )}
150
+ {rarity === 4 && (
151
+ // 最高一档在渐变上叠细斜纹,同样向上消散
152
+ <span
153
+ aria-hidden="true"
154
+ className={cn(
155
+ decor,
156
+ "hatch hatch-fine absolute inset-x-0 bottom-0 h-2/5 opacity-30 [mask-image:linear-gradient(to_top,black,transparent)]",
157
+ )}
158
+ />
159
+ )}
160
+
161
+ <span
162
+ aria-hidden="true"
163
+ className="absolute inset-0 flex items-center justify-center p-3"
164
+ >
165
+ {unowned ? (
166
+ <Crosshair className="size-1/2 max-h-8 max-w-8 text-ink-tertiary" />
167
+ ) : (
168
+ children
169
+ )}
170
+ </span>
171
+
172
+ {rarity && (
173
+ <span
174
+ aria-hidden="true"
175
+ className="absolute top-1/2 left-1.5 flex -translate-y-1/2 flex-col gap-1"
176
+ >
177
+ {Array.from({ length: rarity }, (_, index) => (
178
+ <span key={index} className="size-1 rotate-45 bg-ink-secondary" />
179
+ ))}
180
+ </span>
181
+ )}
182
+
183
+ {isNew && (
184
+ <span
185
+ aria-hidden="true"
186
+ className="cut-br cut-sm absolute top-0 left-0 flex h-4 items-center bg-action pr-2.5 pl-1 font-tech text-micro font-bold text-on-action uppercase"
187
+ >
188
+ {newLabel}
189
+ </span>
190
+ )}
191
+
192
+ {badge !== undefined && (
193
+ <span
194
+ aria-hidden="true"
195
+ className="absolute top-1 right-1 flex size-5 items-center justify-center overflow-clip rounded-full bg-surface-inverse text-micro font-bold text-ink-inverse"
196
+ >
197
+ {badge}
198
+ </span>
199
+ )}
200
+
201
+ {locked && (
202
+ <Lock size={14} className="absolute bottom-1 left-1 text-ink" />
203
+ )}
204
+
205
+ {hasCount && (
206
+ <span
207
+ aria-hidden="true"
208
+ className="absolute right-1.5 bottom-1 font-tech text-sm leading-none font-bold tabular-nums"
209
+ >
210
+ {count}
211
+ </span>
212
+ )}
213
+
214
+ {isUnavailable && (
215
+ <span
216
+ aria-hidden="true"
217
+ // 遮罩在两个主题下都是黑的,上面的字固定用白
218
+ className="absolute inset-0 flex items-center justify-center bg-scrim text-xs font-medium text-neutral-0"
219
+ >
220
+ {unavailableText !== undefined && (
221
+ // 字下面垫一小块近黑,不管底下的图标是什么都读得清
222
+ <span className="bg-neutral-950/80 px-1.5 py-0.5">
223
+ {unavailableText}
224
+ </span>
225
+ )}
226
+ </span>
227
+ )}
228
+
229
+ <span className="sr-only">{spoken}</span>
230
+ </>
231
+ );
232
+
233
+ let node: ReactNode;
234
+ if (isLink) {
235
+ node = (
236
+ <LinkElement
237
+ render={render}
238
+ // 禁用的链接去掉 href,保留 link 角色,让读屏仍能读出"不可用"
239
+ href={disabled ? undefined : href}
240
+ role={disabled ? "link" : undefined}
241
+ aria-disabled={disabled || undefined}
242
+ target={target}
243
+ rel={rel}
244
+ aria-current={selected ? "true" : undefined}
245
+ onClick={disabled ? (event) => event.preventDefault() : onClick}
246
+ className={faceClass}
247
+ {...slotControl}
248
+ >
249
+ {face}
250
+ </LinkElement>
251
+ );
252
+ } else if (isButton) {
253
+ node = (
254
+ <button
255
+ type="button"
256
+ disabled={disabled}
257
+ aria-pressed={selected}
258
+ onClick={onClick}
259
+ className={faceClass}
260
+ {...slotControl}
261
+ >
262
+ {face}
263
+ </button>
264
+ );
265
+ } else {
266
+ node = <div className={faceClass}>{face}</div>;
267
+ }
268
+
269
+ return (
270
+ <div
271
+ {...props}
272
+ data-selected={selected ? "" : undefined}
273
+ data-rarity={rarity}
274
+ className={cn(
275
+ "relative",
276
+ ratioClass[ratio],
277
+ selected && "corner-brackets",
278
+ className,
279
+ )}
280
+ >
281
+ {node}
282
+ </div>
283
+ );
284
+ }
@@ -0,0 +1,7 @@
1
+ export { ItemGrid, type ItemGridProps } from "./ItemGrid";
2
+ export {
3
+ ItemSlot,
4
+ type ItemSlotProps,
5
+ type ItemSlotRarity,
6
+ type ItemSlotRatio,
7
+ } from "./ItemSlot";
@@ -0,0 +1,33 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export type KbdSize = "sm" | "md";
5
+
6
+ export type KbdProps = ComponentProps<"kbd"> & {
7
+ /** 20px 或 24px 高 */
8
+ size?: KbdSize;
9
+ };
10
+
11
+ const sizeClass: Record<KbdSize, string> = {
12
+ sm: "h-5 min-w-5 px-1 text-xs",
13
+ md: "h-6 min-w-6 px-1.5 text-sm",
14
+ };
15
+
16
+ /**
17
+ * 键位提示:一颗小键帽,贴在对应的控件旁。
18
+ * 只用于真的支持键盘快捷键的地方,触屏界面和普通网页不要伪造键帽。
19
+ * 组合键分开写:`<Kbd>Ctrl</Kbd> + <Kbd>K</Kbd>`。
20
+ */
21
+ export function Kbd({ size = "sm", className, ...props }: KbdProps) {
22
+ return (
23
+ <kbd
24
+ {...props}
25
+ className={cn(
26
+ // 反转块:亮色下是墨色键帽,暗色下是游戏界面里的白键帽
27
+ "inline-flex shrink-0 items-center justify-center bg-surface-inverse font-tech leading-none font-medium whitespace-nowrap text-ink-inverse",
28
+ sizeClass[size],
29
+ className,
30
+ )}
31
+ />
32
+ );
33
+ }
@@ -0,0 +1 @@
1
+ export { Kbd, type KbdProps, type KbdSize } from "./Kbd";
@@ -0,0 +1,25 @@
1
+ import { useRender } from "@base-ui/react/use-render";
2
+ import type { ComponentProps, ReactElement } from "react";
3
+ import { defined } from "../../lib/defined";
4
+
5
+ /** 用来代替 `<a>` 的元素,通常是路由库的链接组件:`<Link to="/archive" />` */
6
+ export type LinkRender = ReactElement<Record<string, unknown>>;
7
+
8
+ export type LinkElementProps = ComponentProps<"a"> & {
9
+ render?: LinkRender;
10
+ };
11
+
12
+ /**
13
+ * 各控件"链接形态"里的那个 `<a>`。不对外导出。
14
+ *
15
+ * 传了 `render` 就把控件算好的类名、状态属性和事件合并到那个元素上,
16
+ * 由它来渲染:类名拼在一起,事件两边都触发,其余属性以那个元素自己写的为准。
17
+ */
18
+ export function LinkElement({ render, ref, ...props }: LinkElementProps) {
19
+ return useRender({
20
+ render,
21
+ ref,
22
+ props: render ? defined(props) : props,
23
+ defaultTagName: "a",
24
+ });
25
+ }
@@ -0,0 +1,229 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ type ComponentProps,
5
+ type MouseEvent,
6
+ type ReactNode,
7
+ } from "react";
8
+ import { cn } from "../../lib/cn";
9
+ import { focusRingInset } from "../../lib/focus-ring";
10
+ import { GhostText } from "../ghost-text/GhostText";
11
+ import { LinkElement, type LinkRender } from "../link-element/LinkElement";
12
+ import { bandCanvas, bandGap, bandRow } from "./band-style";
13
+
14
+ export type ListVariant = "plain" | "band";
15
+
16
+ export type ListProps = ComponentProps<"ul"> & {
17
+ /**
18
+ * - `plain` 行间一条 1px 的线,默认;
19
+ * - `band` 浅色画布上一条条固定深色的行,行间留缝;选中行整行反转成白底墨字。
20
+ */
21
+ variant?: ListVariant;
22
+ };
23
+
24
+ const ListVariantContext = createContext<ListVariant>("plain");
25
+
26
+ /** 列表行的容器,外圈不描边。里面只放 `ListRow`。 */
27
+ export function List({ variant = "plain", className, ...props }: ListProps) {
28
+ return (
29
+ <ListVariantContext value={variant}>
30
+ <ul
31
+ {...props}
32
+ data-variant={variant}
33
+ className={cn(
34
+ "text-ink",
35
+ variant === "band"
36
+ ? ["flex flex-col", bandCanvas, bandGap]
37
+ : "divide-y divide-line",
38
+ className,
39
+ )}
40
+ />
41
+ </ListVariantContext>
42
+ );
43
+ }
44
+
45
+ export type ListRowSize = "sm" | "md";
46
+
47
+ export type ListRowProps = Omit<ComponentProps<"li">, "onClick"> & {
48
+ /** 行首:图标、日期块、缩略图 */
49
+ start?: ReactNode;
50
+ /** 行尾:数值或状态,右对齐、等宽 */
51
+ end?: ReactNode;
52
+ /** 标题下的一行次要说明 */
53
+ description?: ReactNode;
54
+ /** 选中:浅灰底 + 左缘黄条 + 标题加粗;在 `band` 列表里是整行反转成白底墨字 */
55
+ selected?: boolean;
56
+ /** 已完成:整行降一档对比,行尾数值前多一个低对比的描边词,读屏多读一句"已完成" */
57
+ completed?: boolean;
58
+ /** 完成态的描边词,默认 `DONE`。纯装饰;传 `null` 去掉 */
59
+ completedWord?: ReactNode;
60
+ /** 40px 或 48px 行高 */
61
+ size?: ListRowSize;
62
+ /** 传了就整行渲染成链接 */
63
+ href?: string;
64
+ target?: string;
65
+ rel?: string;
66
+ /** 用这个元素代替 `<a>`(路由库的链接组件)。传了就按链接处理 */
67
+ render?: LinkRender;
68
+ /** 传了(且没有 `href` 和 `render`)就整行渲染成按钮 */
69
+ onClick?: (event: MouseEvent<HTMLElement>) => void;
70
+ disabled?: boolean;
71
+ };
72
+
73
+ const sizeClass: Record<ListRowSize, string> = {
74
+ sm: "min-h-10 py-2",
75
+ md: "min-h-12 py-2.5",
76
+ };
77
+
78
+ /* 描边词不能比一行文字高,否则会把行撑开 */
79
+ const completedWordSize: Record<ListRowSize, string> = {
80
+ sm: "text-lg",
81
+ md: "text-xl",
82
+ };
83
+
84
+ /**
85
+ * 一行一个条目。整行可点时只有一个可点击元素,不要在里面再嵌按钮。
86
+ * 左缘的黄条只表示"选中",不要拿它表示类目。
87
+ */
88
+ export function ListRow({
89
+ start,
90
+ end,
91
+ description,
92
+ selected,
93
+ completed = false,
94
+ completedWord = "DONE",
95
+ size = "md",
96
+ href,
97
+ target,
98
+ rel,
99
+ render,
100
+ onClick,
101
+ disabled = false,
102
+ className,
103
+ children,
104
+ ...props
105
+ }: ListRowProps) {
106
+ const band = useContext(ListVariantContext) === "band";
107
+ const isLink = href !== undefined || render !== undefined;
108
+ const isButton = !isLink && onClick !== undefined;
109
+ const interactive = (isLink || isButton) && !disabled;
110
+
111
+ const rowClass = cn(
112
+ "relative flex w-full items-center gap-3 px-4 text-left",
113
+ sizeClass[size],
114
+ // 完成:降低对比但保持可读
115
+ completed && "text-ink-secondary",
116
+ // 行带:选中靠整行换主题来表达(见下面的 data-theme),这里只管底和边线
117
+ band && bandRow,
118
+ selected &&
119
+ !band &&
120
+ "bg-surface-muted before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-action before:content-['']",
121
+ interactive && [
122
+ "transition-colors duration-(--duration-fast) ease-standard",
123
+ !selected &&
124
+ (band
125
+ ? // 行带的底是实色,悬停换成提亮一档的实色;半透明的底会把画布透出来
126
+ "hover:bg-surface-raised active:bg-surface-muted"
127
+ : "hover:bg-ink/5 active:bg-ink/10"),
128
+ focusRingInset,
129
+ ],
130
+ disabled && "cursor-not-allowed text-ink-disabled",
131
+ );
132
+
133
+ const content = (
134
+ <>
135
+ {start && <span className="flex shrink-0 items-center">{start}</span>}
136
+ <span className="min-w-0 flex-1">
137
+ <span className={cn("block truncate", selected && "font-bold")}>
138
+ {children}
139
+ {completed && <span className="sr-only">(已完成)</span>}
140
+ </span>
141
+ {description && (
142
+ <span
143
+ className={cn(
144
+ "block truncate text-xs",
145
+ disabled ? "text-ink-disabled" : "text-ink-secondary",
146
+ )}
147
+ >
148
+ {description}
149
+ </span>
150
+ )}
151
+ </span>
152
+ {completed && completedWord !== null && (
153
+ // 排在标题和行尾数值之间,不压在任何文字上;行很窄时让给标题
154
+ <GhostText
155
+ variant="outline"
156
+ className={cn(
157
+ "hidden shrink-0 leading-none @sm:block",
158
+ completedWordSize[size],
159
+ )}
160
+ >
161
+ {completedWord}
162
+ </GhostText>
163
+ )}
164
+ {end && (
165
+ <span
166
+ className={cn(
167
+ "flex shrink-0 items-center font-tech text-sm tabular-nums",
168
+ disabled
169
+ ? "text-ink-disabled"
170
+ : selected
171
+ ? "text-ink"
172
+ : "text-ink-secondary",
173
+ )}
174
+ >
175
+ {end}
176
+ </span>
177
+ )}
178
+ </>
179
+ );
180
+
181
+ let row: ReactNode;
182
+ if (isLink) {
183
+ row = (
184
+ <LinkElement
185
+ render={render}
186
+ // 禁用的链接去掉 href,保留 link 角色,让读屏仍能读出"不可用"
187
+ href={disabled ? undefined : href}
188
+ role={disabled ? "link" : undefined}
189
+ aria-disabled={disabled || undefined}
190
+ target={target}
191
+ rel={rel}
192
+ aria-current={selected ? "true" : undefined}
193
+ onClick={disabled ? (event) => event.preventDefault() : onClick}
194
+ className={rowClass}
195
+ >
196
+ {content}
197
+ </LinkElement>
198
+ );
199
+ } else if (isButton) {
200
+ row = (
201
+ <button
202
+ type="button"
203
+ disabled={disabled}
204
+ // 只有声明了 selected 的行才是"可切换"的
205
+ aria-pressed={selected}
206
+ onClick={onClick}
207
+ className={rowClass}
208
+ >
209
+ {content}
210
+ </button>
211
+ );
212
+ } else {
213
+ row = <div className={rowClass}>{content}</div>;
214
+ }
215
+
216
+ return (
217
+ <li
218
+ // 行带是一块固定的深色,选中的那一行反过来是固定的浅色:
219
+ // 整行换主题,里面的次要文字、焦点环跟着取值
220
+ data-theme={band ? (selected ? "light" : "dark") : undefined}
221
+ {...props}
222
+ data-selected={selected ? "" : undefined}
223
+ data-completed={completed ? "" : undefined}
224
+ className={cn("@container", className)}
225
+ >
226
+ {row}
227
+ </li>
228
+ );
229
+ }
@@ -0,0 +1,18 @@
1
+ /*
2
+ * 行带:浅色画布上一条条固定深色的行。列表的 `band` 变体和数据行带共用。
3
+ *
4
+ * 行带自己是一个局部暗色主题——用 `bandRow` 的元素(或它的祖先)上要写
5
+ * `data-theme="dark"`,行里的次要文字、焦点环才会按深色底取值。
6
+ */
7
+
8
+ /** 画布:凹陷底,四周留 12px */
9
+ export const bandCanvas = "bg-surface-sunken p-3";
10
+
11
+ /** 行与行之间的缝 */
12
+ export const bandGap = "gap-1.5";
13
+
14
+ /**
15
+ * 一条行带。带一圈 1px 的线:亮色页面上看不出来;
16
+ * 暗色页面上画布是纯黑、行带是 #141414,靠它分开。
17
+ */
18
+ export const bandRow = "border border-line bg-surface text-ink";
@@ -0,0 +1,8 @@
1
+ export {
2
+ List,
3
+ ListRow,
4
+ type ListProps,
5
+ type ListRowProps,
6
+ type ListRowSize,
7
+ type ListVariant,
8
+ } from "./List";