@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,443 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ComponentProps,
8
+ type KeyboardEvent,
9
+ } from "react";
10
+ import { useControllableState } from "../../hooks/useControllableState";
11
+ import { ChevronLeft } from "../../icons/ChevronLeft";
12
+ import { ChevronRight } from "../../icons/ChevronRight";
13
+ import { cn } from "../../lib/cn";
14
+ import {
15
+ addDays,
16
+ addMonths,
17
+ addMonthsToMonth,
18
+ clampDate,
19
+ dateParts,
20
+ monthGrid,
21
+ monthOf,
22
+ orderDates,
23
+ startOfWeek,
24
+ toDateObject,
25
+ today as localToday,
26
+ } from "../../lib/date";
27
+ import { focusRingInset } from "../../lib/focus-ring";
28
+ import { IconButton } from "../icon-button/IconButton";
29
+
30
+ /** 一段日期:`[起, 止]`,两头都包含。只有一天时两个一样 */
31
+ export type DateRange = [start: string, end: string];
32
+
33
+ type CalendarBaseProps = Omit<
34
+ ComponentProps<"div">,
35
+ "defaultValue" | "onChange"
36
+ > & {
37
+ /** 现在显示的月份,`YYYY-MM`。不传时跟着选中的那一天(没选就是今天) */
38
+ month?: string;
39
+ defaultMonth?: string;
40
+ onMonthChange?: (month: string) => void;
41
+ /** 能选的最早一天 */
42
+ min?: string;
43
+ /** 能选的最晚一天 */
44
+ max?: string;
45
+ /** 返回真的日子不可选(比如周末、已经约满的日子) */
46
+ isDateDisabled?: (date: string) => boolean;
47
+ /** 一周从哪天开始:1 是星期一(默认),0 是星期日 */
48
+ weekStartsOn?: 0 | 1;
49
+ /** 月份标题、星期、读屏听到的日期名称按它走。默认 `zh-CN` */
50
+ locale?: string;
51
+ /** 哪一天算"今天"。默认取本地时间;服务端渲染或要一个固定的今天时自己传 */
52
+ today?: string;
53
+ previousLabel?: string;
54
+ nextLabel?: string;
55
+ };
56
+
57
+ export type CalendarSingleProps = CalendarBaseProps & {
58
+ range?: false;
59
+ /** 选中的那一天,`YYYY-MM-DD`;没选是 `null` */
60
+ value?: string | null;
61
+ defaultValue?: string | null;
62
+ onValueChange?: (value: string) => void;
63
+ startLabel?: never;
64
+ endLabel?: never;
65
+ pendingLabel?: never;
66
+ };
67
+
68
+ export type CalendarRangeProps = CalendarBaseProps & {
69
+ /** 选一段:点两下,第一下定起始日,第二下定结束日 */
70
+ range: true;
71
+ /** 选中的那一段,`[起, 止]`;没选是 `null` */
72
+ value?: DateRange | null;
73
+ defaultValue?: DateRange | null;
74
+ /** 只在一段选完时触发;后点的那天更早也会排成先后 */
75
+ onValueChange?: (value: DateRange) => void;
76
+ /** 读屏在两端那两天的名称后面听到的词,默认"起始日""结束日" */
77
+ startLabel?: string;
78
+ endLabel?: string;
79
+ /** 起始日定了之后的那句播报,默认"再选结束日" */
80
+ pendingLabel?: string;
81
+ };
82
+
83
+ export type CalendarProps = CalendarSingleProps | CalendarRangeProps;
84
+
85
+ type Selection = string | DateRange | null;
86
+
87
+ const UTC = { timeZone: "UTC" } as const;
88
+
89
+ /** 值有没有变:一段日期每次都是新数组,要按内容比 */
90
+ const keyOf = (value: Selection) =>
91
+ value === null ? "" : typeof value === "string" ? value : value.join("/");
92
+
93
+ /** 键盘把焦点挪到哪一天。返回 `null` 是这个键不归月历管 */
94
+ function moveFocus(
95
+ event: KeyboardEvent,
96
+ from: string,
97
+ weekStartsOn: 0 | 1,
98
+ ): string | null {
99
+ switch (event.key) {
100
+ case "ArrowLeft":
101
+ return addDays(from, -1);
102
+ case "ArrowRight":
103
+ return addDays(from, 1);
104
+ case "ArrowUp":
105
+ return addDays(from, -7);
106
+ case "ArrowDown":
107
+ return addDays(from, 7);
108
+ case "Home":
109
+ return startOfWeek(from, weekStartsOn);
110
+ case "End":
111
+ return addDays(startOfWeek(from, weekStartsOn), 6);
112
+ case "PageUp":
113
+ return addMonths(from, event.shiftKey ? -12 : -1);
114
+ case "PageDown":
115
+ return addMonths(from, event.shiftKey ? 12 : 1);
116
+ default:
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * 月历:选一天;加 `range` 是选一段。日期是 `YYYY-MM-DD` 的字符串。
123
+ * 要一个带面板的字段用 `DatePicker` / `DateRangePicker`。
124
+ */
125
+ export function Calendar({
126
+ range = false,
127
+ value: valueProp,
128
+ defaultValue = null,
129
+ onValueChange,
130
+ month: monthProp,
131
+ defaultMonth,
132
+ onMonthChange,
133
+ min,
134
+ max,
135
+ isDateDisabled,
136
+ weekStartsOn = 1,
137
+ locale = "zh-CN",
138
+ today: todayProp,
139
+ previousLabel = "上个月",
140
+ nextLabel = "下个月",
141
+ startLabel = "起始日",
142
+ endLabel = "结束日",
143
+ pendingLabel = "再选结束日",
144
+ className,
145
+ ...props
146
+ }: CalendarProps) {
147
+ const titleId = useId();
148
+ const gridRef = useRef<HTMLTableSectionElement>(null);
149
+ const [today] = useState(() => todayProp ?? localToday());
150
+ const currentDay = todayProp ?? today;
151
+
152
+ const [value, setValue] = useControllableState<Selection>({
153
+ value: valueProp,
154
+ defaultValue,
155
+ onChange: (next) => {
156
+ if (next !== null) {
157
+ (onValueChange as ((value: string | DateRange) => void) | undefined)?.(
158
+ next,
159
+ );
160
+ }
161
+ },
162
+ });
163
+ // 一段的两头;选一天时两头是同一天
164
+ const committed: DateRange | null =
165
+ value === null ? null : typeof value === "string" ? [value, value] : value;
166
+
167
+ // 选一段时:起始日定了、结束日还没定
168
+ const [anchor, setAnchor] = useState<string | null>(null);
169
+ // 指针停在哪一天(预览用);键盘的位置在 focused 里
170
+ const [hovered, setHovered] = useState<string | null>(null);
171
+
172
+ // 键盘停在哪一天:整个网格只有它能被 Tab 停到
173
+ const [focused, setFocused] = useState(() =>
174
+ clampDate(committed?.[0] ?? currentDay, min, max),
175
+ );
176
+ const [month, setMonth] = useControllableState({
177
+ value: monthProp,
178
+ defaultValue: defaultMonth ?? monthOf(focused),
179
+ onChange: onMonthChange,
180
+ });
181
+ // 键盘移动之后要把焦点交给新的那一格;鼠标翻月不抢焦点
182
+ const pendingFocus = useRef(false);
183
+
184
+ // 从外面换了值(受控):月历跟过去。自己选出来的不算——刚点的那天已经在眼前了
185
+ const valueKey = keyOf(value);
186
+ const lastKey = useRef(valueKey);
187
+ const start = committed?.[0] ?? null;
188
+ useEffect(() => {
189
+ if (valueKey === lastKey.current) return;
190
+ lastKey.current = valueKey;
191
+ setAnchor(null);
192
+ if (start === null) return;
193
+ setFocused(start);
194
+ setMonth(monthOf(start));
195
+ }, [valueKey, start, setMonth]);
196
+
197
+ useEffect(() => {
198
+ if (!pendingFocus.current) return;
199
+ pendingFocus.current = false;
200
+ gridRef.current
201
+ ?.querySelector<HTMLElement>(`[data-date="${focused}"]`)
202
+ ?.focus();
203
+ }, [focused, month]);
204
+
205
+ const weeks = useMemo(
206
+ () => monthGrid(month, weekStartsOn),
207
+ [month, weekStartsOn],
208
+ );
209
+
210
+ const formats = useMemo(
211
+ () => ({
212
+ title: new Intl.DateTimeFormat(locale, {
213
+ ...UTC,
214
+ year: "numeric",
215
+ month: "long",
216
+ }),
217
+ weekdayShort: new Intl.DateTimeFormat(locale, {
218
+ ...UTC,
219
+ weekday: "narrow",
220
+ }),
221
+ weekdayLong: new Intl.DateTimeFormat(locale, { ...UTC, weekday: "long" }),
222
+ day: new Intl.DateTimeFormat(locale, { ...UTC, dateStyle: "full" }),
223
+ }),
224
+ [locale],
225
+ );
226
+
227
+ const unavailable = (date: string) =>
228
+ (min !== undefined && date < min) ||
229
+ (max !== undefined && date > max) ||
230
+ (isDateDisabled?.(date) ?? false);
231
+
232
+ const focusDate = (date: string) => {
233
+ const next = clampDate(date, min, max);
234
+ pendingFocus.current = true;
235
+ setFocused(next);
236
+ if (monthOf(next) !== month) setMonth(monthOf(next));
237
+ };
238
+
239
+ const commit = (next: string | DateRange) => {
240
+ lastKey.current = keyOf(next);
241
+ setValue(next);
242
+ };
243
+
244
+ const select = (date: string) => {
245
+ if (unavailable(date)) return;
246
+ setFocused(date);
247
+ if (monthOf(date) !== month) setMonth(monthOf(date));
248
+ if (!range) {
249
+ commit(date);
250
+ } else if (anchor === null) {
251
+ setAnchor(date);
252
+ } else {
253
+ setAnchor(null);
254
+ commit(orderDates(anchor, date));
255
+ }
256
+ };
257
+
258
+ const turn = (months: number) => {
259
+ const next = addMonthsToMonth(month, months);
260
+ setMonth(next);
261
+ // 停靠点跟到新的月份里同一天(没有那一天就落在月末),否则 Tab 进不来
262
+ setFocused(clampDate(addMonths(focused, months), min, max));
263
+ };
264
+
265
+ const firstOfMonth = `${month}-01`;
266
+ const previousDisabled = min !== undefined && addDays(firstOfMonth, -1) < min;
267
+ const nextDisabled = max !== undefined && addMonths(firstOfMonth, 1) > max;
268
+
269
+ // 停靠点不在眼前这个月(鼠标翻了月)时,借给这个月的第一天
270
+ const tabStop = weeks.flat().includes(focused) ? focused : firstOfMonth;
271
+
272
+ // 现在画的是哪一段:起始日定了就只画它(原来那一段先让开),预览到指针或键盘所在的那天
273
+ const shown: DateRange | null =
274
+ anchor !== null ? [anchor, anchor] : committed;
275
+ const preview =
276
+ anchor !== null ? orderDates(anchor, hovered ?? focused) : null;
277
+
278
+ return (
279
+ <div
280
+ {...props}
281
+ data-month={month}
282
+ data-range={range ? "" : undefined}
283
+ data-pending={anchor !== null ? "" : undefined}
284
+ className={cn("inline-flex flex-col gap-1 text-ink", className)}
285
+ >
286
+ <div className="flex items-center justify-between gap-2">
287
+ <IconButton
288
+ size="sm"
289
+ aria-label={previousLabel}
290
+ disabled={previousDisabled}
291
+ onClick={() => turn(-1)}
292
+ >
293
+ <ChevronLeft />
294
+ </IconButton>
295
+ <div
296
+ id={titleId}
297
+ aria-live="polite"
298
+ className="text-sm font-bold tabular-nums"
299
+ >
300
+ {formats.title.format(toDateObject(firstOfMonth))}
301
+ </div>
302
+ <IconButton
303
+ size="sm"
304
+ aria-label={nextLabel}
305
+ disabled={nextDisabled}
306
+ onClick={() => turn(1)}
307
+ >
308
+ <ChevronRight />
309
+ </IconButton>
310
+ </div>
311
+
312
+ <table
313
+ role="grid"
314
+ aria-labelledby={titleId}
315
+ aria-multiselectable={range || undefined}
316
+ className="border-separate border-spacing-0"
317
+ >
318
+ <thead>
319
+ <tr>
320
+ {weeks[0]!.map((date) => (
321
+ <th
322
+ key={date}
323
+ scope="col"
324
+ abbr={formats.weekdayLong.format(toDateObject(date))}
325
+ className="h-8 w-10 text-xs font-normal text-ink-tertiary"
326
+ >
327
+ {formats.weekdayShort.format(toDateObject(date))}
328
+ </th>
329
+ ))}
330
+ </tr>
331
+ </thead>
332
+ <tbody
333
+ ref={gridRef}
334
+ onPointerLeave={() => setHovered(null)}
335
+ onKeyDown={(event) => {
336
+ // 起始日定了之后按 Esc:取消这一次,回到原来的那一段
337
+ if (event.key === "Escape" && anchor !== null) {
338
+ setAnchor(null);
339
+ return;
340
+ }
341
+ const next = moveFocus(event, focused, weekStartsOn);
342
+ if (next === null) return;
343
+ event.preventDefault();
344
+ // 键盘动了,预览跟着键盘走
345
+ setHovered(null);
346
+ focusDate(next);
347
+ }}
348
+ >
349
+ {weeks.map((week) => (
350
+ <tr key={week[0]}>
351
+ {week.map((date) => {
352
+ const isStart = shown !== null && date === shown[0];
353
+ const isEnd = shown !== null && date === shown[1];
354
+ const endpoint = isStart || isEnd;
355
+ const between =
356
+ shown !== null && date > shown[0] && date < shown[1];
357
+ const previewing =
358
+ preview !== null &&
359
+ !endpoint &&
360
+ date >= preview[0] &&
361
+ date <= preview[1];
362
+ const isToday = date === currentDay;
363
+ const outside = monthOf(date) !== month;
364
+ const disabled = unavailable(date);
365
+ const name = [
366
+ formats.day.format(toDateObject(date)),
367
+ range && isStart && startLabel,
368
+ range && isEnd && endLabel,
369
+ ]
370
+ .filter(Boolean)
371
+ .join(",");
372
+ return (
373
+ <td
374
+ key={date}
375
+ role="gridcell"
376
+ aria-selected={endpoint || between}
377
+ className="p-0"
378
+ >
379
+ <button
380
+ type="button"
381
+ data-date={date}
382
+ data-today={isToday ? "" : undefined}
383
+ data-outside={outside ? "" : undefined}
384
+ data-range-start={range && isStart ? "" : undefined}
385
+ data-range-end={range && isEnd ? "" : undefined}
386
+ data-in-range={between ? "" : undefined}
387
+ data-preview={previewing ? "" : undefined}
388
+ tabIndex={date === tabStop ? 0 : -1}
389
+ aria-label={name}
390
+ aria-current={isToday ? "date" : undefined}
391
+ // 不可选的日子仍然走得到:读屏要能读到它,只是选不了
392
+ aria-disabled={disabled || undefined}
393
+ onClick={() => select(date)}
394
+ onFocus={() => setFocused(date)}
395
+ onPointerEnter={
396
+ range ? () => setHovered(date) : undefined
397
+ }
398
+ className={cn(
399
+ "relative flex size-10 items-center justify-center font-tech text-sm tabular-nums",
400
+ "transition-colors duration-(--duration-fast) ease-standard",
401
+ focusRingInset,
402
+ endpoint
403
+ ? "bg-surface-inverse font-bold text-ink-inverse"
404
+ : [
405
+ // 一段的中间是连着的浅带;还没定结束日时是更浅的预览
406
+ between ? "bg-ink/10" : previewing && "bg-ink/5",
407
+ disabled
408
+ ? "cursor-not-allowed text-ink-disabled line-through"
409
+ : [
410
+ outside ? "text-ink-tertiary" : "text-ink",
411
+ between
412
+ ? "hover:bg-ink/15"
413
+ : "hover:bg-ink/5",
414
+ ],
415
+ ],
416
+ // 今天:数字下面一条短线,不只是换个字色
417
+ isToday &&
418
+ "after:absolute after:bottom-1.5 after:left-1/2 after:h-0.5 after:w-3 after:-translate-x-1/2 after:content-['']",
419
+ isToday &&
420
+ (endpoint
421
+ ? "after:bg-accent-ink-inverse"
422
+ : "after:bg-accent-ink"),
423
+ )}
424
+ >
425
+ {dateParts(date).day}
426
+ </button>
427
+ </td>
428
+ );
429
+ })}
430
+ </tr>
431
+ ))}
432
+ </tbody>
433
+ </table>
434
+ {range && (
435
+ // 起始日定了之后告诉读屏下一步做什么;看得见的人有那块实心的起始日和跟着走的浅带
436
+ <div aria-live="polite" className="sr-only">
437
+ {anchor !== null &&
438
+ `${formats.day.format(toDateObject(anchor))},${startLabel}。${pendingLabel}`}
439
+ </div>
440
+ )}
441
+ </div>
442
+ );
443
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ Calendar,
3
+ type CalendarProps,
4
+ type CalendarRangeProps,
5
+ type CalendarSingleProps,
6
+ type DateRange,
7
+ } from "./Calendar";
@@ -0,0 +1,252 @@
1
+ import {
2
+ Children,
3
+ createContext,
4
+ isValidElement,
5
+ useContext,
6
+ type ComponentProps,
7
+ type KeyboardEvent,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { useControllableState } from "../../hooks/useControllableState";
11
+ import { ChevronLeft } from "../../icons/ChevronLeft";
12
+ import { ChevronRight } from "../../icons/ChevronRight";
13
+ import { cn } from "../../lib/cn";
14
+ import { focusRing } from "../../lib/focus-ring";
15
+ import { DashIndicator } from "../dash-indicator/DashIndicator";
16
+ import { IconButton } from "../icon-button/IconButton";
17
+ import { useSnapTrack } from "./snap-track";
18
+
19
+ export type CarouselRatio = "16/9" | "4/3" | "1/1" | "3/4" | "21/9";
20
+
21
+ const ratioClass: Record<CarouselRatio, string> = {
22
+ "16/9": "aspect-video",
23
+ "4/3": "aspect-4/3",
24
+ "1/1": "aspect-square",
25
+ "3/4": "aspect-3/4",
26
+ "21/9": "aspect-21/9",
27
+ };
28
+
29
+ const SlideContext = createContext<{
30
+ position: number;
31
+ count: number;
32
+ current: boolean;
33
+ } | null>(null);
34
+
35
+ export type CarouselProps = Omit<
36
+ ComponentProps<"section">,
37
+ "aria-label" | "onChange"
38
+ > & {
39
+ /** 这组媒体是什么,比如"玩法介绍" */
40
+ "aria-label": string;
41
+ /** 现在是第几张,从 0 起 */
42
+ index?: number;
43
+ defaultIndex?: number;
44
+ onIndexChange?: (index: number) => void;
45
+ /** 到头之后绕回另一头。默认到头时翻页钮禁用 */
46
+ loop?: boolean;
47
+ /** 媒体的宽高比,默认 `16/9` */
48
+ ratio?: CarouselRatio;
49
+ /** 计数旁边再加一排进度短横 */
50
+ indicator?: boolean;
51
+ previousLabel?: string;
52
+ nextLabel?: string;
53
+ /** 直接放 `CarouselSlide` */
54
+ children: ReactNode;
55
+ };
56
+
57
+ type SlideLike = { title?: ReactNode; description?: ReactNode };
58
+
59
+ /**
60
+ * 媒体轮播:一次看一张的大幅媒体,前后翻。不自动播放。
61
+ * 轨道是原生的横向滚动加滚动吸附,触屏滑动是浏览器自带的。
62
+ */
63
+ export function Carousel({
64
+ index: indexProp,
65
+ defaultIndex = 0,
66
+ onIndexChange,
67
+ loop = false,
68
+ ratio = "16/9",
69
+ indicator = false,
70
+ previousLabel = "上一张",
71
+ nextLabel = "下一张",
72
+ className,
73
+ children,
74
+ ...props
75
+ }: CarouselProps) {
76
+ // 标题和说明从子元素上读,所以子元素必须直接是 CarouselSlide
77
+ const slides = Children.toArray(children).filter((child) =>
78
+ isValidElement<SlideLike>(child),
79
+ );
80
+ const count = slides.length;
81
+ const last = Math.max(0, count - 1);
82
+
83
+ const [rawIndex, setIndex] = useControllableState({
84
+ value: indexProp,
85
+ defaultValue: defaultIndex,
86
+ onChange: onIndexChange,
87
+ });
88
+ const index = Math.min(Math.max(rawIndex, 0), last);
89
+ // 下标和滚动位置互相跟的那一段,和图片查看共用
90
+ const { trackRef, onScroll } = useSnapTrack({
91
+ index,
92
+ count,
93
+ onSettle: setIndex,
94
+ });
95
+
96
+ const go = (next: number) => {
97
+ if (count === 0) return;
98
+ const target = loop
99
+ ? (next + count) % count
100
+ : Math.min(Math.max(next, 0), last);
101
+ setIndex(target);
102
+ // 到头了,刚按的那个翻页钮马上要禁用:把焦点交给媒体,键盘还能接着用方向键翻回去
103
+ if (
104
+ !loop &&
105
+ (target === 0 || target === last) &&
106
+ document.activeElement instanceof HTMLButtonElement &&
107
+ trackRef.current?.parentElement?.contains(document.activeElement)
108
+ ) {
109
+ trackRef.current.focus({ preventScroll: true });
110
+ }
111
+ };
112
+
113
+ const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
114
+ const next = {
115
+ ArrowLeft: index - 1,
116
+ ArrowRight: index + 1,
117
+ Home: 0,
118
+ End: last,
119
+ }[event.key];
120
+ if (next === undefined) return;
121
+ event.preventDefault();
122
+ go(next);
123
+ };
124
+
125
+ const current = slides[index] as { props: SlideLike } | undefined;
126
+ const place = `第 ${index + 1} 张,共 ${count} 张`;
127
+
128
+ return (
129
+ <section
130
+ {...props}
131
+ role="group"
132
+ aria-roledescription="轮播"
133
+ data-ratio={ratio}
134
+ className={cn("flex flex-col gap-3 text-ink", className)}
135
+ >
136
+ <div className="relative isolate">
137
+ <div
138
+ ref={trackRef}
139
+ // 能聚焦:键盘要能在媒体上用方向键翻页
140
+ tabIndex={0}
141
+ onScroll={onScroll}
142
+ // 只管焦点就在轨道上的时候:幻灯片里的控件(视频的进度条)自己要用方向键
143
+ onKeyDown={(event) => {
144
+ if (event.target === event.currentTarget) onKeyDown(event);
145
+ }}
146
+ className={cn(
147
+ "flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain bg-surface-muted [scrollbar-width:none]",
148
+ // 环画在外面:画在里面会被幻灯片盖住(它们是定位元素,画在轨道的轮廓之上)
149
+ focusRing,
150
+ ratioClass[ratio],
151
+ )}
152
+ >
153
+ {slides.map((slide, position) => (
154
+ <SlideContext
155
+ key={position}
156
+ value={{ position, count, current: position === index }}
157
+ >
158
+ {slide}
159
+ </SlideContext>
160
+ ))}
161
+ </div>
162
+ {/* 压在媒体左下角的一对圆钮。它们在轨道外面:轨道滚的时候不跟着走 */}
163
+ <div
164
+ onKeyDown={onKeyDown}
165
+ className="absolute bottom-3 left-3 z-1 flex gap-2"
166
+ >
167
+ <IconButton
168
+ variant="floating"
169
+ aria-label={previousLabel}
170
+ disabled={!loop && index <= 0}
171
+ onClick={() => go(index - 1)}
172
+ >
173
+ <ChevronLeft />
174
+ </IconButton>
175
+ <IconButton
176
+ variant="floating"
177
+ aria-label={nextLabel}
178
+ disabled={!loop && index >= last}
179
+ onClick={() => go(index + 1)}
180
+ >
181
+ <ChevronRight />
182
+ </IconButton>
183
+ </div>
184
+ </div>
185
+
186
+ <div className="flex flex-col gap-1">
187
+ {/* 看得见的计数和短横只给眼睛:同样的话下面的说明区会播报 */}
188
+ <div aria-hidden="true" className="flex items-center gap-3">
189
+ <span className="font-tech text-xs text-ink-secondary tabular-nums">
190
+ {`${index + 1} / ${count}`}
191
+ </span>
192
+ {indicator && <DashIndicator count={count} index={index} />}
193
+ </div>
194
+ <div aria-live="polite" className="flex flex-col gap-1">
195
+ <span className="sr-only">{place}</span>
196
+ {current?.props.title && (
197
+ <p className="text-lg font-bold wrap-anywhere">
198
+ {current.props.title}
199
+ </p>
200
+ )}
201
+ {current?.props.description && (
202
+ <p className="text-sm text-ink-secondary">
203
+ {current.props.description}
204
+ </p>
205
+ )}
206
+ </div>
207
+ </div>
208
+ </section>
209
+ );
210
+ }
211
+
212
+ export type CarouselSlideProps = Omit<ComponentProps<"div">, "title"> & {
213
+ /** 这一张的标题,显示在媒体下方 */
214
+ title?: ReactNode;
215
+ /** 标题下面的一段正文 */
216
+ description?: ReactNode;
217
+ /** 媒体:一张图或一段视频,铺满并裁切 */
218
+ children: ReactNode;
219
+ };
220
+
221
+ /** 轮播里的一张。直接放在 `Carousel` 里。 */
222
+ export function CarouselSlide({
223
+ // 标题和说明由 Carousel 读走,画在媒体下面
224
+ title: _title,
225
+ description: _description,
226
+ className,
227
+ children,
228
+ ...props
229
+ }: CarouselSlideProps) {
230
+ const slide = useContext(SlideContext);
231
+ if (!slide) {
232
+ throw new Error("CarouselSlide 要直接放在 Carousel 里面");
233
+ }
234
+
235
+ return (
236
+ <div
237
+ {...props}
238
+ role="group"
239
+ aria-roledescription="幻灯片"
240
+ aria-label={`第 ${slide.position + 1} 张,共 ${slide.count} 张`}
241
+ // 不在眼前的:里面的链接、视频控件 Tab 走不进去,读屏也读不到
242
+ inert={!slide.current}
243
+ data-current={slide.current ? "" : undefined}
244
+ className={cn(
245
+ "relative size-full shrink-0 snap-start overflow-clip *:size-full *:object-cover",
246
+ className,
247
+ )}
248
+ >
249
+ {children}
250
+ </div>
251
+ );
252
+ }