@endfield-ui/react 0.2.0 → 1.0.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 (77) hide show
  1. package/NOTICE.md +2 -2
  2. package/README.md +103 -8
  3. package/dist/index.d.ts +78 -17
  4. package/dist/index.js +1184 -264
  5. package/dist/styles.css +1 -1
  6. package/package.json +1 -1
  7. package/src/components/README.md +11 -2
  8. package/src/components/alert/Alert.tsx +63 -1
  9. package/src/components/avatar/Avatar.tsx +5 -2
  10. package/src/components/bracket-title/BracketTitle.tsx +32 -3
  11. package/src/components/button/Button.tsx +37 -5
  12. package/src/components/calendar/Calendar.tsx +49 -5
  13. package/src/components/checkbox/Checkbox.tsx +1 -0
  14. package/src/components/checkbox/choice-style.ts +13 -5
  15. package/src/components/chip/capsule-style.ts +18 -3
  16. package/src/components/combobox/Combobox.tsx +10 -0
  17. package/src/components/completion-banner/CompletionBanner.tsx +30 -2
  18. package/src/components/corner-brackets/CornerBrackets.tsx +2 -1
  19. package/src/components/dash-indicator/DashIndicator.tsx +5 -2
  20. package/src/components/data-row/DataRow.tsx +2 -1
  21. package/src/components/date-picker/DateField.tsx +5 -1
  22. package/src/components/dialog/Dialog.tsx +4 -2
  23. package/src/components/dropdown-menu/MenuCheck.tsx +4 -2
  24. package/src/components/dropdown-menu/menu-style.ts +22 -5
  25. package/src/components/empty-state/EmptyState.tsx +5 -0
  26. package/src/components/field/Field.tsx +39 -7
  27. package/src/components/file-upload/FileUpload.tsx +79 -20
  28. package/src/components/icon-button/IconButton.tsx +18 -5
  29. package/src/components/image-viewer/ImageViewer.tsx +58 -18
  30. package/src/components/input/control-box.ts +23 -2
  31. package/src/components/input/value-chip.ts +3 -2
  32. package/src/components/item-slot/ItemSlot.tsx +2 -1
  33. package/src/components/loader/Loader.tsx +12 -41
  34. package/src/components/nav-menu/NavMenu.tsx +17 -3
  35. package/src/components/otp-input/OtpInput.tsx +13 -4
  36. package/src/components/page-header/PageHeader.tsx +39 -3
  37. package/src/components/pagination/Pagination.tsx +2 -0
  38. package/src/components/progress/Progress.tsx +10 -3
  39. package/src/components/progress/ProgressRing.tsx +19 -4
  40. package/src/components/radio/Radio.tsx +2 -0
  41. package/src/components/rolling-number/RollingNumber.tsx +125 -0
  42. package/src/components/rolling-number/index.ts +1 -0
  43. package/src/components/schedule/Schedule.tsx +5 -3
  44. package/src/components/segmented-control/SegmentedControl.tsx +25 -4
  45. package/src/components/skeleton/Skeleton.tsx +4 -1
  46. package/src/components/slider/Slider.tsx +26 -5
  47. package/src/components/sparkline/Sparkline.tsx +29 -5
  48. package/src/components/spinner/Spinner.tsx +6 -2
  49. package/src/components/stepper/Stepper.tsx +4 -0
  50. package/src/components/steps/Steps.tsx +30 -9
  51. package/src/components/switch/Switch.tsx +26 -2
  52. package/src/components/table/Table.tsx +33 -9
  53. package/src/components/tabs/Tabs.tsx +55 -9
  54. package/src/components/tag-input/TagInput.tsx +82 -43
  55. package/src/components/textarea/Textarea.tsx +2 -1
  56. package/src/components/timeline/Timeline.tsx +4 -3
  57. package/src/components/toast/Toast.tsx +18 -5
  58. package/src/components/toc/Toc.tsx +89 -7
  59. package/src/components/toolbar/Toolbar.tsx +24 -0
  60. package/src/components/tooltip/Tooltip.tsx +5 -1
  61. package/src/components/viewfinder/Viewfinder.tsx +9 -0
  62. package/src/hooks/README.md +8 -2
  63. package/src/hooks/index.ts +1 -0
  64. package/src/hooks/useAddedKeys.ts +26 -0
  65. package/src/hooks/useFlip.ts +211 -0
  66. package/src/hooks/useIndicator.ts +135 -0
  67. package/src/hooks/useLingering.ts +122 -0
  68. package/src/hooks/usePresence.ts +100 -0
  69. package/src/hooks/useReducedMotion.ts +26 -0
  70. package/src/hooks/useTheme.ts +44 -4
  71. package/src/index.ts +1 -0
  72. package/src/lib/README.md +2 -0
  73. package/src/lib/cn.ts +3 -0
  74. package/src/lib/inline-style.ts +12 -0
  75. package/src/lib/motion.ts +90 -0
  76. package/src/styles/theme.css +81 -2
  77. package/src/styles/utilities.css +2 -1
@@ -28,10 +28,12 @@ export function MenuCheck({ checked, disabled = false }: MenuCheckProps) {
28
28
  ? [
29
29
  "border-ink-disabled bg-surface-sunken text-ink-disabled",
30
30
  // 菱形方案里"已选"只靠实心表达,禁用了也要保持实心
31
- checked && "choice-diamond:bg-ink-disabled",
31
+ checked &&
32
+ "choice-diamond:bg-ink-disabled forced-colors:choice-diamond:bg-[GrayText]",
32
33
  ]
33
34
  : checked
34
- ? "border-surface-inverse bg-surface-inverse text-accent-ink-inverse"
35
+ ? // 高对比模式下反转块没了:和复选框一样改用系统的"选中"色
36
+ "border-surface-inverse bg-surface-inverse text-accent-ink-inverse forced-colors:border-[Highlight] forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]"
35
37
  : "border-line-strong bg-surface-sunken",
36
38
  )}
37
39
  >
@@ -1,5 +1,6 @@
1
1
  import { createContext } from "react";
2
2
  import { focusRingInset } from "../../lib/focus-ring";
3
+ import { enterFromSide } from "../../lib/motion";
3
4
 
4
5
  /*
5
6
  * 下拉菜单与下拉选择共用的面板和选项。
@@ -12,11 +13,17 @@ export type MenuVariant = "plain" | "strong";
12
13
  /** 选项要知道自己在哪种面板里,才知道"当前项"怎么画 */
13
14
  export const MenuVariantContext = createContext<MenuVariant>("plain");
14
15
 
15
- /** 面板:直角、1px 线、环境阴影;顶部一条半宽的行动色细条,展开时从 0 伸到一半宽 */
16
+ /**
17
+ * 面板:直角、1px 线、环境阴影;顶部一条半宽的行动色细条,展开时从 0 伸到一半宽。
18
+ *
19
+ * 进场:淡入(200ms),同时从触发处那一侧挪过来 4px(300ms,和细条同一拍)。
20
+ * 退场只淡出——位移只写在起点上。两个时长、两条缓动按属性一一对应
21
+ */
16
22
  export const menuPanel = [
17
23
  "relative min-w-40 border border-line bg-surface-raised py-1 text-ink shadow-sm outline-none",
18
- "origin-(--transform-origin) transition-opacity duration-(--duration-fast) ease-standard",
24
+ "origin-(--transform-origin) transition-[opacity,translate] duration-[var(--duration-fast),var(--duration-base)] ease-[var(--ease-standard),var(--ease-exit)]",
19
25
  "data-ending-style:opacity-0 data-starting-style:opacity-0",
26
+ enterFromSide,
20
27
  "before:pointer-events-none before:absolute before:-top-px before:-left-px before:h-[3px] before:w-1/2 before:origin-left before:bg-action before:content-['']",
21
28
  "before:transition-transform before:duration-(--duration-base) before:ease-exit data-starting-style:before:scale-x-0",
22
29
  "forced-colors:before:hidden",
@@ -35,11 +42,21 @@ export const menuItem = [
35
42
  /** 不是当前项的选项才有悬停底:当前项的底不能被悬停盖掉 */
36
43
  export const menuItemHover = "data-highlighted:bg-ink/5";
37
44
 
38
- /** 当前项(下拉选择的已选项、单选菜单项的选中项) */
45
+ /**
46
+ * 当前项(下拉选择的已选项、单选菜单项的选中项)。
47
+ *
48
+ * `strong` 的当前项是一整行黄底,而面板是深色的局部主题,焦点色也是黄的:
49
+ * 画在里面的焦点环换成压得住黄底的颜色,并且再往里收 2px——那个颜色是墨色,
50
+ * 贴着边的话和深色的面板分不开,看上去只是黄底小了一圈。
51
+ *
52
+ * 高对比模式下底色都被系统换掉:两种面板的当前项都是左缘一条系统"选中"色的竖条
53
+ * (`strong` 平时没有这一条,只在这时候画)
54
+ */
39
55
  export const menuItemCurrent: Record<MenuVariant, string> = {
40
56
  plain:
41
- "bg-surface-muted font-medium before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-ink before:content-['']",
42
- strong: "bg-action font-medium text-on-action",
57
+ "bg-surface-muted font-medium before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-ink before:content-[''] forced-colors:before:bg-[Highlight]",
58
+ strong:
59
+ "bg-action font-medium text-on-action focus-visible:-outline-offset-4 focus-visible:outline-on-action forced-colors:before:absolute forced-colors:before:inset-y-0 forced-colors:before:left-0 forced-colors:before:w-[3px] forced-colors:before:bg-[Highlight] forced-colors:before:content-['']",
43
60
  };
44
61
 
45
62
  export const menuGroupLabel = "px-3 pt-2 pb-1 text-xs text-ink-tertiary";
@@ -14,6 +14,8 @@ export type EmptyStateProps = Omit<ComponentProps<"div">, "title"> & {
14
14
  bordered?: boolean;
15
15
  /** 标题层级,默认 3(`<h3>`) */
16
16
  level?: 2 | 3 | 4 | 5 | 6;
17
+ /** 挂上时淡入一次,不"啪"地一下出现。默认开启 */
18
+ animate?: boolean;
17
19
  };
18
20
 
19
21
  /** 取景角 + 准星:游戏内图鉴用它表示"未获得",空状态沿用这个符号 */
@@ -45,6 +47,7 @@ export function EmptyState({
45
47
  icon,
46
48
  bordered = true,
47
49
  level = 3,
50
+ animate = true,
48
51
  className,
49
52
  children,
50
53
  ...props
@@ -58,6 +61,8 @@ export function EmptyState({
58
61
  className={cn(
59
62
  "flex flex-col items-center px-6 py-10 text-center text-ink",
60
63
  bordered && "border border-dashed border-line-strong",
64
+ // 入场只是淡入,不加位移;挂上就播,不等进视口——它要管的就是挂上的那一刻
65
+ animate && "animate-fade-in",
61
66
  className,
62
67
  )}
63
68
  >
@@ -3,11 +3,14 @@ import {
3
3
  useContext,
4
4
  useId,
5
5
  useMemo,
6
+ useState,
6
7
  type HTMLAttributes,
7
8
  type ReactNode,
8
9
  } from "react";
10
+ import { usePresence } from "../../hooks/usePresence";
9
11
  import { StatusDanger } from "../../icons/StatusDanger";
10
12
  import { cn } from "../../lib/cn";
13
+ import { collapse, collapseEnter, collapseInner } from "../../lib/motion";
11
14
 
12
15
  type FieldContextValue = {
13
16
  /** 单个控件的 id;`group` 字段里有多个控件,不提供 */
@@ -106,6 +109,16 @@ export function Field({
106
109
  [hasError && errorId, hasHelp && helpId].filter(Boolean).join(" ") ||
107
110
  undefined;
108
111
 
112
+ // 错误说明撤掉之后先收起、再卸载。收的时候 `error` 已经空了,画的是最后那一句
113
+ const {
114
+ ref: errorRef,
115
+ mounted: errorMounted,
116
+ leaving: errorLeaving,
117
+ entered: errorEntered,
118
+ } = usePresence<HTMLDivElement>(hasError);
119
+ const [lastError, setLastError] = useState(error);
120
+ if (hasError && !Object.is(lastError, error)) setLastError(error);
121
+
109
122
  const context = useMemo<FieldContextValue>(
110
123
  () => ({
111
124
  controlId: group ? undefined : id,
@@ -143,14 +156,33 @@ export function Field({
143
156
 
144
157
  const messages = (
145
158
  <>
146
- {hasError && (
147
- <p
148
- id={errorId}
149
- className="mt-1.5 flex items-start gap-1.5 text-sm text-danger"
159
+ {errorMounted && (
160
+ // 出现时按高度把下面的内容顶开并淡入,撤掉时反过来:下面的不会被"啪"地顶一下
161
+ <div
162
+ ref={errorRef}
163
+ data-leaving={errorLeaving ? "" : undefined}
164
+ // 正在收起的是已经撤掉的那一句,不再读给读屏
165
+ aria-hidden={errorLeaving || undefined}
166
+ className={cn(
167
+ collapse,
168
+ // 小件:高度和淡入淡出都是 200ms
169
+ "transition-[grid-template-rows,opacity] duration-(--duration-fast) data-leaving:opacity-0",
170
+ errorEntered && [collapseEnter, "starting:opacity-0"],
171
+ )}
150
172
  >
151
- <StatusDanger size={16} className="mt-0.5 shrink-0" />
152
- <span className="min-w-0 wrap-anywhere">{error}</span>
153
- </p>
173
+ <div className={collapseInner}>
174
+ {/* 和控件之间的 6px 写成内边距,跟着一起收 */}
175
+ <p
176
+ id={errorId}
177
+ className="flex items-start gap-1.5 pt-1.5 text-sm text-danger"
178
+ >
179
+ <StatusDanger size={16} className="mt-0.5 shrink-0" />
180
+ <span className="min-w-0 wrap-anywhere">
181
+ {hasError ? error : lastError}
182
+ </span>
183
+ </p>
184
+ </div>
185
+ </div>
154
186
  )}
155
187
  {hasHelp && (
156
188
  <p
@@ -7,7 +7,9 @@ import {
7
7
  type ReactNode,
8
8
  type Ref,
9
9
  } from "react";
10
+ import { useAddedKeys } from "../../hooks/useAddedKeys";
10
11
  import { useControllableState } from "../../hooks/useControllableState";
12
+ import { useLingering } from "../../hooks/useLingering";
11
13
  import { StatusDanger } from "../../icons/StatusDanger";
12
14
  import { Upload } from "../../icons/Upload";
13
15
  import { cn } from "../../lib/cn";
@@ -15,6 +17,7 @@ import { decor } from "../../lib/decor";
15
17
  import { formatFileSize, matchesAccept, sameFile } from "../../lib/file";
16
18
  import { focusRingWithin } from "../../lib/focus-ring";
17
19
  import { mergeRefs } from "../../lib/merge-refs";
20
+ import { collapse, fadeInFast } from "../../lib/motion";
18
21
  import { useFieldControl } from "../field/Field";
19
22
  import { FileItem } from "./FileItem";
20
23
 
@@ -104,6 +107,10 @@ const zoneSize: Record<FileUploadSize, string> = {
104
107
  md: "gap-3 px-4 py-4",
105
108
  };
106
109
 
110
+ /** 列表里认一个文件靠的那串字 */
111
+ const fileKey = (file: File) =>
112
+ `${file.name}-${file.size}-${file.lastModified}`;
113
+
107
114
  /** 拖进来的东西里的文件;文件夹不收 */
108
115
  function droppedFiles(transfer: DataTransfer): File[] {
109
116
  const items = [...(transfer.items ?? [])];
@@ -115,6 +122,46 @@ function droppedFiles(transfer: DataTransfer): File[] {
115
122
  });
116
123
  }
117
124
 
125
+ /**
126
+ * 列表里的一行。淡入、收起都写在这一层:里面那一行可能是使用方自己画的(renderFile)。
127
+ * 移除的那一行先留着——高度收到 0 并淡出,连它占的 4px 行距一起收,收完才卸载;
128
+ * 下面的行和控件后面的内容是被带着上来的。
129
+ */
130
+ function FileRow({
131
+ leaving,
132
+ entering,
133
+ children,
134
+ }: {
135
+ leaving: boolean;
136
+ entering: boolean;
137
+ children: ReactNode;
138
+ }) {
139
+ // 走的时候外面不再给内容了(那个文件在使用方那边可能已经清掉):画的是最后一次的
140
+ const [last, setLast] = useState(children);
141
+ if (!leaving && last !== children) setLast(children);
142
+
143
+ return (
144
+ <li
145
+ data-leaving={leaving ? "" : undefined}
146
+ // 正在收起的:点不到、Tab 不进去,读屏也读不到
147
+ inert={leaving}
148
+ aria-hidden={leaving || undefined}
149
+ className={cn(
150
+ collapse,
151
+ // 小件:高度、行距、淡出都是 200ms
152
+ "transition-[grid-template-rows,margin,opacity] duration-(--duration-fast)",
153
+ "data-leaving:-mb-1 data-leaving:opacity-0",
154
+ entering && fadeInFast,
155
+ )}
156
+ >
157
+ {/* 只在收的时候裁:平时里面的焦点环要画得出去 */}
158
+ <div className="min-h-0 min-w-0 in-data-leaving:overflow-y-clip">
159
+ {leaving ? last : children}
160
+ </div>
161
+ </li>
162
+ );
163
+ }
164
+
118
165
  /**
119
166
  * 文件上传:选文件,或者把文件拖进来。它只管"选"和"列"——发请求、算进度是使用方的事。
120
167
  *
@@ -164,6 +211,11 @@ export function FileUpload({
164
211
  defaultValue: defaultValue ?? [],
165
212
  onChange: onValueChange,
166
213
  });
214
+ const keys = files.map(fileKey);
215
+ // 后来加进来的那几行淡入;一开始就有的是静止的
216
+ const added = useAddedKeys(keys);
217
+ // 移除的那一行先留着收起来,收完才从页面里拿掉
218
+ const lingering = useLingering<HTMLUListElement>(keys);
167
219
  const [rejections, setRejections] = useState<FileRejection[]>([]);
168
220
  const [dragging, setDragging] = useState(false);
169
221
  // dragenter / dragleave 在每个子元素上都会来一次:数着,归零才算真的离开
@@ -190,8 +242,10 @@ export function FileUpload({
190
242
  if (index === null) return;
191
243
  pendingFocus.current = null;
192
244
  const buttons =
193
- listRef.current?.querySelectorAll<HTMLElement>("[data-file-remove]") ??
194
- [];
245
+ // 正在收起的那一行不算
246
+ listRef.current?.querySelectorAll<HTMLElement>(
247
+ "li:not([data-leaving]) [data-file-remove]",
248
+ ) ?? [];
195
249
  const target = buttons[Math.min(index, buttons.length - 1)];
196
250
  if (target) target.focus();
197
251
  else inputRef.current?.focus();
@@ -348,7 +402,7 @@ export function FileUpload({
348
402
  data-brackets=""
349
403
  className={cn(
350
404
  decor,
351
- "corner-brackets absolute inset-1.5 [--bracket-offset:0px]",
405
+ "corner-brackets absolute inset-1.5 [--bracket-offset:0px] after:animate-bracket-in",
352
406
  )}
353
407
  />
354
408
  )}
@@ -391,28 +445,33 @@ export function FileUpload({
391
445
  )}
392
446
  </div>
393
447
 
394
- {files.length > 0 && (
448
+ {lingering.entries.length > 0 && (
395
449
  <ul
396
- ref={listRef}
450
+ ref={mergeRefs(listRef, lingering.ref)}
397
451
  aria-label={labels.list}
398
- className="flex flex-col gap-1"
452
+ // 最后一行也在收了:列表和上面之间那 8px 跟着收,卸载的那一刻后面的内容不跳
453
+ data-emptying={files.length === 0 ? "" : undefined}
454
+ className="flex flex-col gap-1 transition-[margin] duration-(--duration-fast) ease-standard data-emptying:-mt-2"
399
455
  >
400
- {files.map((file, index) => {
456
+ {lingering.entries.map(({ key, leaving }) => {
457
+ const index = keys.indexOf(key);
458
+ const file = files[index];
401
459
  const actions = { remove: () => remove(index), index };
402
460
  return (
403
- <li key={`${file.name}-${file.size}-${file.lastModified}`}>
404
- {renderFile ? (
405
- renderFile(file, actions)
406
- ) : (
407
- <FileItem
408
- name={file.name}
409
- size={file.size}
410
- disabled={disabled}
411
- removeLabel={removeLabel(file.name)}
412
- onRemove={actions.remove}
413
- />
414
- )}
415
- </li>
461
+ <FileRow key={key} leaving={leaving} entering={added.has(key)}>
462
+ {file !== undefined &&
463
+ (renderFile ? (
464
+ renderFile(file, actions)
465
+ ) : (
466
+ <FileItem
467
+ name={file.name}
468
+ size={file.size}
469
+ disabled={disabled}
470
+ removeLabel={removeLabel(file.name)}
471
+ onRemove={actions.remove}
472
+ />
473
+ ))}
474
+ </FileRow>
416
475
  );
417
476
  })}
418
477
  </ul>
@@ -21,8 +21,9 @@ export type IconButtonProps = Omit<ComponentProps<"button">, "aria-label"> & {
21
21
  };
22
22
 
23
23
  const sizeClass: Record<IconButtonSize, string> = {
24
- // 32px 小于触屏的最小点击区,用伪元素向外补到 40px
25
- sm: "size-8 after:absolute after:-inset-1 after:content-[''] [&_svg]:size-4",
24
+ // 32px 小于触屏的最小点击区,用伪元素向外补到 40px。
25
+ // 高对比模式下多了一圈 1px 的线,伪元素是从线的里沿起算的,多补 1px 才还是 40px
26
+ sm: "size-8 after:absolute after:-inset-1 after:content-[''] forced-colors:after:-inset-[5px] [&_svg]:size-4",
26
27
  md: "size-10 [&_svg]:size-5",
27
28
  lg: "size-12 [&_svg]:size-6",
28
29
  };
@@ -50,6 +51,17 @@ const pressedClass: Record<IconButtonVariant, string> = {
50
51
  inverse: "bg-surface-inverse text-accent-ink-inverse",
51
52
  };
52
53
 
54
+ /*
55
+ * 高对比模式下底色都被系统换掉:没激活的只剩一个图标,看不出是按钮;激活的和没激活的也成了
56
+ * 一个样子。所以都补一圈线(圆的那种是圆圈)——没激活的是按钮的字色,禁用的是灰字色,
57
+ * 激活的整块改用系统的"选中"色。线三种状态都有,换状态时图标不会挪
58
+ */
59
+ const outlineForced = "forced-colors:border";
60
+ const enabledForced = "forced-colors:border-[ButtonText]";
61
+ const pressedForced =
62
+ "forced-colors:border-[Highlight] forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]";
63
+ const disabledForced = "forced-colors:border-[GrayText]";
64
+
53
65
  const disabledClass: Record<IconButtonVariant, string> = {
54
66
  plain: "cursor-not-allowed bg-surface-sunken text-ink-disabled",
55
67
  floating:
@@ -81,11 +93,12 @@ export function IconButton({
81
93
  "transition-colors duration-(--duration-fast) ease-standard",
82
94
  focusRing,
83
95
  sizeClass[size],
96
+ outlineForced,
84
97
  disabled
85
- ? disabledClass[variant]
98
+ ? [disabledClass[variant], disabledForced]
86
99
  : pressed
87
- ? pressedClass[variant]
88
- : enabledClass[variant],
100
+ ? [pressedClass[variant], pressedForced]
101
+ : [enabledClass[variant], enabledForced],
89
102
  className,
90
103
  )}
91
104
  >
@@ -5,6 +5,7 @@ import {
5
5
  isValidElement,
6
6
  useContext,
7
7
  useRef,
8
+ useState,
8
9
  type ComponentProps,
9
10
  type KeyboardEvent,
10
11
  type ReactElement,
@@ -279,7 +280,12 @@ function Stage({
279
280
  </BaseDialog.Close>
280
281
  </div>
281
282
 
282
- <Viewfinder size="md" className="min-h-0 flex-1">
283
+ {/* 取景角等整层淡入完了再落位:一起动的话被淡入盖住,看不见 */}
284
+ <Viewfinder
285
+ size="md"
286
+ animate
287
+ className="min-h-0 flex-1 [--bracket-delay:var(--duration-fast)]"
288
+ >
283
289
  <div
284
290
  ref={trackRef}
285
291
  data-image-viewer-track=""
@@ -287,27 +293,15 @@ function Stage({
287
293
  className="absolute inset-0 flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain [scrollbar-width:none]"
288
294
  >
289
295
  {items.map((item, position) => (
290
- <div
296
+ <Slide
291
297
  key={position}
292
- role="group"
293
- aria-roledescription="幻灯片"
294
- aria-label={`第 ${position + 1} 张,共 ${count} 张`}
295
- // 不在眼前的:Tab 走不进去,读屏也读不到
296
- inert={position !== index}
297
- data-current={position === index ? "" : undefined}
298
- // 点图以外的空处就是关
299
- onClick={(event) => {
300
- if (event.target === event.currentTarget) onClose();
301
- }}
302
- className={cn(
303
- "flex size-full shrink-0 snap-start items-center justify-center p-6 sm:p-10",
304
- // 图按原比例整张放进去:不裁切,也不放大过它自己的尺寸
305
- "*:max-h-full *:max-w-full [&>img]:object-contain [&>video]:object-contain",
306
- )}
298
+ label={`第 ${position + 1} 张,共 ${count} 张`}
299
+ current={position === index}
300
+ onClose={onClose}
307
301
  >
308
302
  {/* 只渲染当前这张和左右各一张:二十张图不会一打开就全去取 */}
309
303
  {Math.abs(position - index) <= 1 ? item.props.children : null}
310
- </div>
304
+ </Slide>
311
305
  ))}
312
306
  </div>
313
307
  </Viewfinder>
@@ -360,6 +354,52 @@ function Stage({
360
354
  );
361
355
  }
362
356
 
357
+ type SlideProps = {
358
+ label: string;
359
+ current: boolean;
360
+ onClose: () => void;
361
+ children: ReactNode;
362
+ };
363
+
364
+ /**
365
+ * 轨道上的一张。图是使用方给的元素,这里不碰它,只在外面听它的 `load`:
366
+ * 还没取到的图是透明的,取到了淡入——不是"啪"地一下出现。
367
+ * 只管直接放进来的 `<img>`;视频、别的内容不等,和原来一样直接在
368
+ */
369
+ function Slide({ label, current, onClose, children }: SlideProps) {
370
+ // 取到过一次就算数:翻远了再翻回来,图是现成的,不用再藏一次
371
+ const [loaded, setLoaded] = useState(false);
372
+ const settle = () => setLoaded(true);
373
+
374
+ return (
375
+ <div
376
+ role="group"
377
+ aria-roledescription="幻灯片"
378
+ aria-label={label}
379
+ // 不在眼前的:Tab 走不进去,读屏也读不到
380
+ inert={!current}
381
+ data-current={current ? "" : undefined}
382
+ data-loaded={loaded ? "" : undefined}
383
+ // load 和 error 不冒泡,但捕获阶段从外往里走,在这一层听得到。
384
+ // 取不到的图也要露出来:使用方写的替代文字在那里
385
+ onLoadCapture={settle}
386
+ onErrorCapture={settle}
387
+ // 点图以外的空处就是关
388
+ onClick={(event) => {
389
+ if (event.target === event.currentTarget) onClose();
390
+ }}
391
+ className={cn(
392
+ "flex size-full shrink-0 snap-start items-center justify-center p-6 sm:p-10",
393
+ // 图按原比例整张放进去:不裁切,也不放大过它自己的尺寸
394
+ "*:max-h-full *:max-w-full [&>img]:object-contain [&>video]:object-contain",
395
+ "[&>img]:transition-opacity [&>img]:duration-(--duration-fast) [&>img]:ease-standard not-data-loaded:[&>img]:opacity-0",
396
+ )}
397
+ >
398
+ {children}
399
+ </div>
400
+ );
401
+ }
402
+
363
403
  export type ImageViewerItemProps = Omit<
364
404
  ComponentProps<"li">,
365
405
  "title" | "aria-label"
@@ -41,6 +41,12 @@ const fill: Record<ControlVariant, string> = {
41
41
  outline: "bg-surface",
42
42
  };
43
43
 
44
+ /**
45
+ * 只读时留着占位的那条透明边线,高对比模式下写成页面色:系统会把透明的边线也换成字色
46
+ * 显出来,而底色本来就都没了——只读的就和能填的一个样。输入框和验证码输入共用
47
+ */
48
+ export const readOnlyFrame = "forced-colors:border-[Canvas]";
49
+
44
50
  export function controlBox({
45
51
  variant = "sunken",
46
52
  invalid,
@@ -56,6 +62,8 @@ export function controlBox({
56
62
  ? // 只读:没有边线、底变透明。边线留透明的,尺寸不跳
57
63
  "border-transparent bg-transparent"
58
64
  : fill[variant],
65
+ // 只在透明的那条线真的作数时:禁用、错误各有自己的边线颜色,排在后面盖掉它
66
+ readOnly && !disabled && !invalid && readOnlyFrame,
59
67
  disabled
60
68
  ? "cursor-not-allowed border-line text-ink-disabled"
61
69
  : invalid
@@ -66,6 +74,19 @@ export function controlBox({
66
74
  );
67
75
  }
68
76
 
69
- /** 框里真正的 `<input>` / `<textarea>`:没有自己的底和边,焦点环画在外框上 */
77
+ /**
78
+ * 框里真正的 `<input>` / `<textarea>`:没有自己的底和边,焦点环画在外框上。
79
+ *
80
+ * 高对比模式下禁用的字色要自己写明:平时的 `ink-disabled` 写在外框上,系统换掉之后
81
+ * 只有它自己认得的禁用控件才是灰字色——`<textarea>`、写了 `type` 的 `<input>` 认得,
82
+ * 没写 `type` 的 `<input>`(输入框、组合框都是)不认,字和没禁用的一个颜色
83
+ */
70
84
  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";
85
+ "min-w-0 flex-1 bg-transparent outline-none placeholder:text-ink-tertiary disabled:cursor-not-allowed disabled:placeholder:text-ink-disabled forced-colors:disabled:text-[GrayText]";
86
+
87
+ /**
88
+ * 框里跟着禁用变浅、但自己不是控件的字(计数):`ink-disabled` 写在 `<span>` 上,
89
+ * 高对比模式下系统当普通字换掉,写明灰字色
90
+ */
91
+ export const controlDisabledText =
92
+ "text-ink-disabled forced-colors:text-[GrayText]";
@@ -12,12 +12,13 @@ export const valueChipSize: Record<ControlSize, string> = {
12
12
  lg: "h-8 text-base",
13
13
  };
14
14
 
15
+ // 高对比模式下底色被系统换掉,小块就成了一串连着的字:补一圈线,一块还是一块
15
16
  export const valueChip =
16
- "flex max-w-full min-w-0 cursor-default items-center bg-surface-muted pl-2 text-ink";
17
+ "flex max-w-full min-w-0 cursor-default items-center bg-surface-muted pl-2 text-ink forced-colors:border forced-colors:border-[ButtonText]";
17
18
 
18
19
  /** 小块右端的删除叉:一个和小块等高的方钮 */
19
20
  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
+ "flex aspect-square h-full shrink-0 items-center justify-center text-ink-secondary hover:bg-line hover:text-ink forced-colors:in-data-disabled:text-[GrayText]";
21
22
 
22
23
  /** 和小块排在一起的那个输入框:和小块一样高,这一行才不会比小块高出一截 */
23
24
  export const valueInputHeight: Record<ControlSize, string> = {
@@ -274,7 +274,8 @@ export function ItemSlot({
274
274
  className={cn(
275
275
  "relative",
276
276
  ratioClass[ratio],
277
- selected && "corner-brackets",
277
+ // 选中的那一刻括号从角上伸出来;取消时直接消失
278
+ selected && "corner-brackets after:animate-bracket-in",
278
279
  className,
279
280
  )}
280
281
  >
@@ -1,13 +1,12 @@
1
1
  import {
2
2
  useEffect,
3
- useRef,
4
- useState,
5
3
  type ComponentProps,
6
4
  type KeyboardEvent,
7
5
  type ReactNode,
8
- type TransitionEvent,
9
6
  } from "react";
7
+ import { usePresence } from "../../hooks/usePresence";
10
8
  import { cn } from "../../lib/cn";
9
+ import { mergeRefs } from "../../lib/merge-refs";
11
10
 
12
11
  export type LoaderProps = Omit<ComponentProps<"div">, "children"> & {
13
12
  /** 真实进度,0 – 100。不知道就不传:显示不确定进度,不显示数字 */
@@ -24,9 +23,6 @@ export type LoaderProps = Omit<ComponentProps<"div">, "children"> & {
24
23
  onExited?: () => void;
25
24
  };
26
25
 
27
- /** 滑出的时长,与 `--duration-slower` 一致;过渡事件没来时靠它兜底 */
28
- const EXIT_MS = 600;
29
-
30
26
  /**
31
27
  * 加载页:近黑底、大号等宽百分比、一行标语、黄色进度条。只用于整页的首次加载。
32
28
  * 不伪造进度;内容就绪后立刻把 `open` 置为 `false`,不要为了播完动画而等待。
@@ -39,32 +35,15 @@ export function Loader({
39
35
  label = "加载中",
40
36
  onExited,
41
37
  onKeyDown,
42
- onTransitionEnd,
43
38
  className,
39
+ ref,
44
40
  ...props
45
41
  }: LoaderProps) {
46
- const rootRef = useRef<HTMLDivElement>(null);
47
- const [mounted, setMounted] = useState(open);
48
- // 重新打开:立刻挂回来
49
- if (open && !mounted) setMounted(true);
50
-
51
- const onExitedRef = useRef(onExited);
52
- useEffect(() => {
53
- onExitedRef.current = onExited;
42
+ // 退出:滑出的过渡走完后卸载;重新打开立刻挂回来
43
+ const { ref: rootRef, mounted } = usePresence<HTMLDivElement>(open, {
44
+ onExited,
54
45
  });
55
46
 
56
- const leaving = !open && mounted;
57
-
58
- // 退出:滑出的过渡结束后卸载。没有过渡的环境里事件不会来,由定时器兜底
59
- useEffect(() => {
60
- if (!leaving) return;
61
- const timer = window.setTimeout(() => {
62
- setMounted(false);
63
- onExitedRef.current?.();
64
- }, EXIT_MS + 100);
65
- return () => window.clearTimeout(timer);
66
- }, [leaving]);
67
-
68
47
  // 整页模式:加载期间锁住页面滚动,把焦点收进来;开始退出时就放开
69
48
  useEffect(() => {
70
49
  if (!fullscreen || !open) return;
@@ -79,18 +58,10 @@ export function Loader({
79
58
  previousFocus.focus({ preventScroll: true });
80
59
  }
81
60
  };
82
- }, [fullscreen, open]);
61
+ }, [fullscreen, open, rootRef]);
83
62
 
84
63
  if (!mounted) return null;
85
64
 
86
- const handleTransitionEnd = (event: TransitionEvent<HTMLDivElement>) => {
87
- onTransitionEnd?.(event);
88
- if (leaving && event.target === event.currentTarget) {
89
- setMounted(false);
90
- onExitedRef.current?.();
91
- }
92
- };
93
-
94
65
  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
95
66
  onKeyDown?.(event);
96
67
  // 整页加载时焦点留在加载页上,不让 Tab 走到被盖住的内容里
@@ -102,12 +73,11 @@ export function Loader({
102
73
 
103
74
  return (
104
75
  <div
105
- ref={rootRef}
106
76
  tabIndex={-1}
107
77
  {...props}
78
+ ref={mergeRefs(rootRef, ref)}
108
79
  data-state={open ? "open" : "closing"}
109
80
  onKeyDown={handleKeyDown}
110
- onTransitionEnd={handleTransitionEnd}
111
81
  className={cn(
112
82
  // 有意不随主题变:加载页是固定的近黑底
113
83
  "@container flex flex-col justify-end bg-neutral-950 text-neutral-0 outline-none",
@@ -137,13 +107,14 @@ export function Loader({
137
107
  aria-valuemin={0}
138
108
  aria-valuemax={100}
139
109
  aria-valuenow={indeterminate ? undefined : current}
140
- className="relative h-1.5 overflow-hidden rounded-sm bg-neutral-800"
110
+ // 高对比模式下底色都被系统换掉:轨道剩一圈线,填充用系统的"选中"色(同 Progress)
111
+ className="relative h-1.5 overflow-hidden rounded-sm bg-neutral-800 forced-colors:outline forced-colors:-outline-offset-1 forced-colors:outline-[CanvasText]"
141
112
  >
142
113
  {indeterminate ? (
143
- <span className="absolute inset-y-0 left-0 w-2/5 animate-indeterminate bg-action motion-reduce:translate-x-3/4" />
114
+ <span className="absolute inset-y-0 left-0 w-2/5 animate-indeterminate bg-action motion-reduce:translate-x-3/4 forced-colors:bg-[Highlight]" />
144
115
  ) : (
145
116
  <span
146
- className="absolute inset-0 origin-left bg-action transition-transform duration-(--duration-base) ease-standard"
117
+ className="absolute inset-0 origin-left bg-action transition-transform duration-(--duration-base) ease-standard forced-colors:bg-[Highlight]"
147
118
  style={{ transform: `scaleX(${current / 100})` }}
148
119
  />
149
120
  )}