@endfield-ui/react 0.3.0 → 1.0.1

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 (70) hide show
  1. package/NOTICE.md +2 -2
  2. package/README.md +103 -8
  3. package/dist/index.d.ts +69 -15
  4. package/dist/index.js +1104 -239
  5. package/dist/styles.css +1 -1
  6. package/package.json +1 -1
  7. package/src/components/README.md +12 -3
  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/carousel/Carousel.tsx +2 -1
  14. package/src/components/checkbox/Checkbox.tsx +1 -0
  15. package/src/components/checkbox/choice-style.ts +9 -4
  16. package/src/components/chip/capsule-style.ts +18 -3
  17. package/src/components/combobox/Combobox.tsx +10 -0
  18. package/src/components/completion-banner/CompletionBanner.tsx +30 -2
  19. package/src/components/dash-indicator/DashIndicator.tsx +10 -3
  20. package/src/components/data-row/DataRow.tsx +2 -1
  21. package/src/components/date-picker/DateField.tsx +5 -1
  22. package/src/components/dropdown-menu/MenuCheck.tsx +4 -2
  23. package/src/components/dropdown-menu/menu-style.ts +13 -3
  24. package/src/components/empty-state/EmptyState.tsx +5 -0
  25. package/src/components/field/Field.tsx +39 -7
  26. package/src/components/file-upload/FileUpload.tsx +78 -19
  27. package/src/components/icon-button/IconButton.tsx +18 -5
  28. package/src/components/input/control-box.ts +23 -2
  29. package/src/components/input/value-chip.ts +3 -2
  30. package/src/components/loader/Loader.tsx +12 -41
  31. package/src/components/nav-menu/NavMenu.tsx +3 -2
  32. package/src/components/otp-input/OtpInput.tsx +8 -2
  33. package/src/components/page-header/PageHeader.tsx +39 -3
  34. package/src/components/pagination/Pagination.tsx +2 -0
  35. package/src/components/progress/Progress.tsx +10 -3
  36. package/src/components/progress/ProgressRing.tsx +19 -4
  37. package/src/components/radio/Radio.tsx +2 -0
  38. package/src/components/rolling-number/RollingNumber.tsx +125 -0
  39. package/src/components/rolling-number/index.ts +1 -0
  40. package/src/components/schedule/Schedule.tsx +5 -3
  41. package/src/components/segmented-control/SegmentedControl.tsx +25 -4
  42. package/src/components/skeleton/Skeleton.tsx +4 -1
  43. package/src/components/slider/Slider.tsx +21 -4
  44. package/src/components/sparkline/Sparkline.tsx +29 -5
  45. package/src/components/spinner/Spinner.tsx +6 -2
  46. package/src/components/stepper/Stepper.tsx +4 -0
  47. package/src/components/steps/Steps.tsx +18 -6
  48. package/src/components/switch/Switch.tsx +26 -2
  49. package/src/components/table/Table.tsx +33 -9
  50. package/src/components/tabs/Tabs.tsx +55 -9
  51. package/src/components/tag-input/TagInput.tsx +82 -43
  52. package/src/components/textarea/Textarea.tsx +2 -1
  53. package/src/components/timeline/Timeline.tsx +4 -3
  54. package/src/components/toc/Toc.tsx +89 -7
  55. package/src/components/toolbar/Toolbar.tsx +24 -0
  56. package/src/hooks/README.md +8 -2
  57. package/src/hooks/index.ts +1 -0
  58. package/src/hooks/useAddedKeys.ts +26 -0
  59. package/src/hooks/useFlip.ts +211 -0
  60. package/src/hooks/useIndicator.ts +135 -0
  61. package/src/hooks/useLingering.ts +122 -0
  62. package/src/hooks/usePresence.ts +100 -0
  63. package/src/hooks/useReducedMotion.ts +26 -0
  64. package/src/hooks/useTheme.ts +44 -4
  65. package/src/index.ts +1 -0
  66. package/src/lib/README.md +2 -1
  67. package/src/lib/cn.ts +1 -0
  68. package/src/lib/inline-style.ts +12 -0
  69. package/src/lib/motion.ts +75 -0
  70. package/src/styles/theme.css +38 -2
@@ -26,7 +26,11 @@ export function DashIndicator({
26
26
  role="img"
27
27
  aria-label={`第 ${current + 1} 项,共 ${total} 项`}
28
28
  {...props}
29
- className={cn("inline-flex items-center gap-1", className)}
29
+ className={cn(
30
+ // 放不下时各根等比收窄,不顶出容器:短横本来就能收,卡住的是这一层自己的最小宽度
31
+ "inline-flex max-w-full min-w-0 items-center gap-1",
32
+ className,
33
+ )}
30
34
  >
31
35
  {Array.from({ length: total }, (_, position) => (
32
36
  <span
@@ -34,8 +38,11 @@ export function DashIndicator({
34
38
  data-current={position === current ? "" : undefined}
35
39
  className={cn(
36
40
  "h-0.5 transition-[width,background-color] duration-(--duration-base) ease-standard",
37
- // 当前一根除了换色还加长一倍,不只靠颜色
38
- position === current ? "w-8 bg-accent-ink" : "w-4 bg-line-strong",
41
+ // 当前一根除了换色还加长一倍,不只靠颜色。
42
+ // 高对比模式下底色被系统换掉,整排都没了:当前一根用系统的"选中"色,其余用页面的字色
43
+ position === current
44
+ ? "w-8 bg-accent-ink forced-colors:bg-[Highlight]"
45
+ : "w-4 bg-line-strong forced-colors:bg-[CanvasText]",
39
46
  )}
40
47
  />
41
48
  ))}
@@ -167,7 +167,8 @@ const toneClass: Record<DataRowTone, string> = {
167
167
  };
168
168
 
169
169
  const favoriteDot = "block size-4 rounded-full";
170
- const favoriteOn = "bg-action";
170
+ /* 高对比模式下底色被系统换掉,实心的圆就没了:改用系统的"选中"色 */
171
+ const favoriteOn = "bg-action forced-colors:bg-[Highlight]";
171
172
  /* 未收藏是一个空心的圆环:形状也不同,不只靠颜色 */
172
173
  const favoriteOff = "border-2 border-ink-tertiary";
173
174
 
@@ -123,7 +123,11 @@ export function DateField({
123
123
  size={iconSize}
124
124
  className={cn(
125
125
  "shrink-0",
126
- disabled ? "text-ink-disabled" : "text-ink-secondary",
126
+ // 高对比模式:写在 <svg> 自己身上的颜色系统不换(它只替图标换继承来的字色),
127
+ // 主题里的灰会原样压在使用者定的页面色上。写明系统色,和按钮里的字一起走
128
+ disabled
129
+ ? "text-ink-disabled forced-colors:text-[GrayText]"
130
+ : "text-ink-secondary forced-colors:text-[ButtonText]",
127
131
  )}
128
132
  />
129
133
  </BasePopover.Trigger>
@@ -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
  >
@@ -42,11 +42,21 @@ export const menuItem = [
42
42
  /** 不是当前项的选项才有悬停底:当前项的底不能被悬停盖掉 */
43
43
  export const menuItemHover = "data-highlighted:bg-ink/5";
44
44
 
45
- /** 当前项(下拉选择的已选项、单选菜单项的选中项) */
45
+ /**
46
+ * 当前项(下拉选择的已选项、单选菜单项的选中项)。
47
+ *
48
+ * `strong` 的当前项是一整行黄底,而面板是深色的局部主题,焦点色也是黄的:
49
+ * 画在里面的焦点环换成压得住黄底的颜色,并且再往里收 2px——那个颜色是墨色,
50
+ * 贴着边的话和深色的面板分不开,看上去只是黄底小了一圈。
51
+ *
52
+ * 高对比模式下底色都被系统换掉:两种面板的当前项都是左缘一条系统"选中"色的竖条
53
+ * (`strong` 平时没有这一条,只在这时候画)
54
+ */
46
55
  export const menuItemCurrent: Record<MenuVariant, string> = {
47
56
  plain:
48
- "bg-surface-muted font-medium before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-ink before:content-['']",
49
- 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-['']",
50
60
  };
51
61
 
52
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();
@@ -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
  >
@@ -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> = {
@@ -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
  )}
@@ -226,8 +226,9 @@ export function NavMenuItem({
226
226
  // 黄和旁边的浅灰明度很接近:另加粗体和左缘一条墨色竖条,不只靠颜色
227
227
  "bg-action font-bold text-on-action",
228
228
  "before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-on-action before:content-['']",
229
- // 普通的焦点色在暗色主题下也是黄的,压在黄底上看不见
230
- "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-on-action",
229
+ // 普通的焦点色在暗色主题下也是黄的,压在黄底上看不见。换成墨色之后再往里收 2px:
230
+ // 贴着边的话它在暗色页面上和页面分不开,看上去只是黄条小了一圈
231
+ "focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-on-action",
231
232
  ]
232
233
  : [
233
234
  "bg-surface-sunken text-ink hover:bg-surface-muted active:bg-line",
@@ -3,7 +3,11 @@ import { Fragment, useId, type ComponentProps } from "react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { focusRing } from "../../lib/focus-ring";
5
5
  import { useFieldContext, useFieldControl } from "../field/Field";
6
- import type { ControlSize, ControlVariant } from "../input/control-box";
6
+ import {
7
+ readOnlyFrame,
8
+ type ControlSize,
9
+ type ControlVariant,
10
+ } from "../input/control-box";
7
11
 
8
12
  export type OtpInputType = "numeric" | "alphanumeric";
9
13
  export type OtpInputSize = ControlSize;
@@ -141,6 +145,7 @@ export function OtpInput({
141
145
  ? // 只读:没有边线、底变透明。边线留透明的,尺寸不跳
142
146
  "border-transparent bg-transparent"
143
147
  : slotFill[variant],
148
+ readOnly && !disabled && !invalid && readOnlyFrame,
144
149
  disabled
145
150
  ? "cursor-not-allowed border-line"
146
151
  : invalid
@@ -194,7 +199,8 @@ export function OtpInput({
194
199
  <span
195
200
  aria-hidden="true"
196
201
  data-separator=""
197
- className="h-0.5 w-2 shrink-0 bg-line-strong"
202
+ // 高对比模式下底色被系统换掉,短横就没了,只剩一段比别处宽的空:换成页面的字色
203
+ className="h-0.5 w-2 shrink-0 bg-line-strong forced-colors:bg-[CanvasText]"
198
204
  />
199
205
  )}
200
206
  <BaseOtpField.Input