@endfield-ui/react 1.1.0 → 1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@endfield-ui/react",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "终末地风格的 React 基础控件库(非官方)",
5
5
  "keywords": [
6
6
  "react",
@@ -62,6 +62,7 @@
62
62
  | `skeleton/` | `Skeleton` | [反馈](../../../../docs/design/components/feedback.md) |
63
63
  | `empty-state/` | `EmptyState` | [反馈](../../../../docs/design/components/feedback.md) |
64
64
  | `loader/` | `Loader` | [反馈](../../../../docs/design/components/feedback.md) |
65
+ | `splash-screen/` | `SplashScreen`(开屏页:照官网首次载入的那一页排的,标志和底图是插槽) | [反馈](../../../../docs/design/components/feedback.md#开屏页) |
65
66
  | `spinner/` | `Spinner` | [反馈](../../../../docs/design/components/feedback.md) |
66
67
  | `completion-banner/` | `CompletionBanner` | [反馈](../../../../docs/design/components/feedback.md) |
67
68
  | `tooltip/` | `Tooltip`、`TooltipProvider` | [浮层](../../../../docs/design/components/overlay.md) |
@@ -126,7 +126,7 @@ export function Alert({
126
126
  // 正在收起的提示条点不到、Tab 不进去
127
127
  inert={leaving}
128
128
  className={cn(
129
- "flex items-start gap-3 border border-l-4 border-line bg-surface-raised py-3 pr-3 pl-3 text-sm text-ink",
129
+ "flex items-start gap-3 border-l-4 bg-surface-raised py-3 pr-3 pl-3 text-sm text-ink",
130
130
  toneBar[tone],
131
131
  leaving && [
132
132
  "pointer-events-none overflow-hidden",
@@ -91,8 +91,6 @@ export function DateField({
91
91
  data-invalid={invalid ? "" : undefined}
92
92
  className={cn(
93
93
  controlBox({ variant, invalid, disabled, readOnly: false }),
94
- // 面板开着的时候边线保持聚焦的墨色
95
- !disabled && !invalid && "has-data-popup-open:border-ink",
96
94
  controlSize[size],
97
95
  className,
98
96
  )}
@@ -16,6 +16,10 @@ export const MenuVariantContext = createContext<MenuVariant>("plain");
16
16
  /**
17
17
  * 面板:直角、1px 线、环境阴影;顶部一条半宽的行动色细条,展开时从 0 伸到一半宽。
18
18
  *
19
+ * 细条上挂一个朝下的小三角,和它是同一个伪元素裁出来的:条高 3px,三角露出 8×4px,
20
+ * 中心距条的左端 32px(实机风格弹窗分隔条的一半大)。它是条上的记号,不指向触发处——
21
+ * 面板翻到上面、在侧边时照旧在顶上、朝下。三角探进第一行 2px,所以压在各行的底色之上
22
+ *
19
23
  * 进场:淡入(200ms),同时从触发处那一侧挪过来 4px(300ms,和细条同一拍)。
20
24
  * 退场只淡出——位移只写在起点上。两个时长、两条缓动按属性一一对应
21
25
  */
@@ -24,7 +28,8 @@ export const menuPanel = [
24
28
  "origin-(--transform-origin) transition-[opacity,translate] duration-[var(--duration-fast),var(--duration-base)] ease-[var(--ease-standard),var(--ease-exit)]",
25
29
  "data-ending-style:opacity-0 data-starting-style:opacity-0",
26
30
  enterFromSide,
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-['']",
31
+ "before:pointer-events-none before:absolute before:-top-px before:-left-px before:z-1 before:h-[7px] before:w-1/2 before:origin-left before:bg-action before:content-['']",
32
+ "before:[clip-path:polygon(0_0,100%_0,100%_3px,36px_3px,32px_100%,28px_3px,0_3px)]",
28
33
  "before:transition-transform before:duration-(--duration-base) before:ease-exit data-starting-style:before:scale-x-0",
29
34
  "forced-colors:before:hidden",
30
35
  ].join(" ");
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  useEffect,
3
+ useLayoutEffect,
3
4
  useRef,
4
5
  useState,
5
6
  type ChangeEvent,
@@ -235,9 +236,11 @@ export function FileUpload({
235
236
  }
236
237
  }, [files, revision]);
237
238
 
238
- // 移除之后焦点落到哪:等这一次渲染完
239
+ // 移除之后焦点落到哪:等这一次渲染落到页面上,当场交出去。
240
+ // 不用 useEffect:它晚一拍,上一次渲染留下的那一份可能在这次移除之后才补跑——
241
+ // 看到的还是移除之前的页面,焦点留在马上要收起的那一行上,随后掉到 body
239
242
  const pendingFocus = useRef<number | null>(null);
240
- useEffect(() => {
243
+ useLayoutEffect(() => {
241
244
  const index = pendingFocus.current;
242
245
  if (index === null) return;
243
246
  pendingFocus.current = null;
@@ -5,7 +5,7 @@ import { focusRingWithin } from "../../lib/focus-ring";
5
5
  * 输入框与多行文本共用的外框,直角,两种画法:
6
6
  * - `sunken` 凹陷的底 + 一条 2px 的底边线,默认;
7
7
  * - `outline` 四边 1px 的描边 + 页面底色,放在凹陷底色的区域里时用。
8
- * 状态都靠边线的颜色变化:默认灰 → 悬停深一档 → 聚焦墨 → 错误红。
8
+ * 状态靠边线说:默认灰 → 悬停深一档 → 错误红;聚焦另画一条墨线。
9
9
  */
10
10
 
11
11
  export type ControlVariant = "sunken" | "outline";
@@ -41,11 +41,29 @@ 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]";
44
+ /* 画在 `::before` 上:分段选择的 `::after` 是选中的那块底 */
45
+ const lineFrame: Record<ControlVariant, string> = {
46
+ sunken:
47
+ "before:pointer-events-none before:absolute before:inset-x-0 before:-bottom-[2px] before:h-[2px] before:origin-left before:scale-x-0 before:bg-ink before:content-[''] forced-colors:before:bg-[Highlight]",
48
+ outline:
49
+ "before:pointer-events-none before:absolute before:-inset-px before:border before:border-ink before:[clip-path:inset(0_100%_0_0)] before:content-['']",
50
+ };
51
+
52
+ const lineOpen: Record<ControlVariant, string> = {
53
+ sunken:
54
+ "focus-within:before:scale-x-100 has-data-popup-open:before:scale-x-100",
55
+ outline:
56
+ "focus-within:before:[clip-path:inset(0)] has-data-popup-open:before:[clip-path:inset(0)]",
57
+ };
58
+
59
+ /* 擦出 300ms、收回 200ms */
60
+ const lineMotion =
61
+ "before:transition-[scale,clip-path] before:duration-(--duration-fast) before:ease-standard focus-within:before:duration-(--duration-base) focus-within:before:ease-exit has-data-popup-open:before:duration-(--duration-base) has-data-popup-open:before:ease-exit";
62
+
63
+ /** 聚焦的墨线,接在带 `relative` 的外框上用 */
64
+ export function controlLine(variant: ControlVariant = "sunken") {
65
+ return cn(lineFrame[variant], lineOpen[variant], lineMotion);
66
+ }
49
67
 
50
68
  export function controlBox({
51
69
  variant = "sunken",
@@ -54,7 +72,7 @@ export function controlBox({
54
72
  readOnly,
55
73
  }: BoxState) {
56
74
  return cn(
57
- "flex w-full min-w-0 text-ink",
75
+ "relative flex w-full min-w-0 text-ink",
58
76
  frame[variant],
59
77
  "transition-colors duration-(--duration-fast) ease-standard",
60
78
  focusRingWithin,
@@ -62,15 +80,17 @@ export function controlBox({
62
80
  ? // 只读:没有边线、底变透明。边线留透明的,尺寸不跳
63
81
  "border-transparent bg-transparent"
64
82
  : fill[variant],
65
- // 只在透明的那条线真的作数时:禁用、错误各有自己的边线颜色,排在后面盖掉它
66
- readOnly && !disabled && !invalid && readOnlyFrame,
83
+ // 高对比模式下系统会把透明的边线也换成字色显出来,而底色本来就都没了:
84
+ // 只读的写成页面色,不然和能填的一个样
85
+ readOnly && !disabled && !invalid && "forced-colors:border-[Canvas]",
67
86
  disabled
68
87
  ? "cursor-not-allowed border-line text-ink-disabled"
69
88
  : invalid
70
89
  ? "border-danger"
71
90
  : !readOnly &&
72
91
  // 悬停只在没聚焦时生效:生成的 CSS 里 hover 排在 focus-within 之后,会盖掉聚焦的墨色
73
- "border-line-strong hover:not-focus-within:border-ink-secondary focus-within:border-ink",
92
+ "border-line-strong hover:not-focus-within:border-ink-secondary",
93
+ !disabled && !invalid && !readOnly && controlLine(variant),
74
94
  );
75
95
  }
76
96
 
@@ -1,10 +1,10 @@
1
1
  import { OTPField as BaseOtpField } from "@base-ui/react/otp-field";
2
2
  import { Fragment, useId, type ComponentProps } from "react";
3
3
  import { cn } from "../../lib/cn";
4
- import { focusRing } from "../../lib/focus-ring";
5
4
  import { useFieldContext, useFieldControl } from "../field/Field";
6
5
  import {
7
- readOnlyFrame,
6
+ controlBox,
7
+ controlElement,
8
8
  type ControlSize,
9
9
  type ControlVariant,
10
10
  } from "../input/control-box";
@@ -53,21 +53,17 @@ export type OtpInputProps = Omit<
53
53
  slotLabel?: (position: number, length: number) => string;
54
54
  };
55
55
 
56
- /* 格子见方;容器窄了宽度可以让,高度不变 */
56
+ /* 格子见方;容器窄了宽度可以让,高度不变。字号在里面那个 <input> 上 */
57
57
  const slotSize: Record<ControlSize, string> = {
58
- sm: "h-8 w-8 text-base",
59
- md: "h-10 w-10 text-lg",
60
- lg: "h-14 w-14 text-2xl",
58
+ sm: "h-8 w-8",
59
+ md: "h-10 w-10",
60
+ lg: "h-14 w-14",
61
61
  };
62
62
 
63
- const slotFrame: Record<ControlVariant, string> = {
64
- sunken: "border-b-2",
65
- outline: "border",
66
- };
67
-
68
- const slotFill: Record<ControlVariant, string> = {
69
- sunken: "bg-surface-sunken",
70
- outline: "bg-surface",
63
+ const slotText: Record<ControlSize, string> = {
64
+ sm: "text-base",
65
+ md: "text-lg",
66
+ lg: "text-2xl",
71
67
  };
72
68
 
73
69
  const gapClass: Record<ControlSize, string> = {
@@ -132,27 +128,12 @@ export function OtpInput({
132
128
  const ownLabel = ariaLabelledBy ?? (ariaLabel ? labelId : undefined);
133
129
  const groupLabel = ownLabel ?? fieldContext?.labelId;
134
130
 
135
- const slotClass = cn(
136
- // 字色:空格是透明的,填上(基元给的 data-filled)才有颜色。下面那条颜色的过渡
137
- // 本来是给边线的,字色跟着它走——所以填进去的那一位是淡入的。光标另有自己的颜色
138
- "min-w-0 shrink text-center font-tech font-medium tabular-nums text-transparent caret-ink",
131
+ const inputClass = cn(
132
+ controlElement,
133
+ "h-full w-full text-center font-tech font-medium tabular-nums caret-ink text-transparent",
139
134
  "transition-colors duration-(--duration-fast) ease-standard",
140
135
  disabled ? "data-filled:text-ink-disabled" : "data-filled:text-ink",
141
- focusRing,
142
- slotSize[size],
143
- slotFrame[variant],
144
- readOnly
145
- ? // 只读:没有边线、底变透明。边线留透明的,尺寸不跳
146
- "border-transparent bg-transparent"
147
- : slotFill[variant],
148
- readOnly && !disabled && !invalid && readOnlyFrame,
149
- disabled
150
- ? "cursor-not-allowed border-line"
151
- : invalid
152
- ? "border-danger"
153
- : !readOnly &&
154
- // 悬停只在没聚焦时生效:聚焦中的格子不会因为鼠标经过而变浅
155
- "border-line-strong hover:not-focus:border-ink-secondary focus:border-ink",
136
+ slotText[size],
156
137
  );
157
138
 
158
139
  const group = groupSize !== undefined && groupSize > 0 ? groupSize : 0;
@@ -203,16 +184,27 @@ export function OtpInput({
203
184
  className="h-0.5 w-2 shrink-0 bg-line-strong forced-colors:bg-[CanvasText]"
204
185
  />
205
186
  )}
206
- <BaseOtpField.Input
207
- // 第一格的名称是字段的标签(或者上面那段隐藏的文字),说明文字也挂在它上面
208
- {...(index === 0
209
- ? { "aria-labelledby": ownLabel, "aria-describedby": describedBy }
210
- : { "aria-label": slotLabel(index + 1, length) })}
211
- aria-invalid={invalid || undefined}
212
- autoFocus={autoFocus && index === 0}
213
- autoCapitalize={uppercase ? "characters" : "none"}
214
- className={slotClass}
215
- />
187
+ <span
188
+ className={cn(
189
+ controlBox({ variant, invalid, disabled, readOnly }),
190
+ "shrink items-center justify-center",
191
+ slotSize[size],
192
+ )}
193
+ >
194
+ <BaseOtpField.Input
195
+ // 第一格的名称是字段的标签(或者上面那段隐藏的文字),说明文字也挂在它上面
196
+ {...(index === 0
197
+ ? {
198
+ "aria-labelledby": ownLabel,
199
+ "aria-describedby": describedBy,
200
+ }
201
+ : { "aria-label": slotLabel(index + 1, length) })}
202
+ aria-invalid={invalid || undefined}
203
+ autoFocus={autoFocus && index === 0}
204
+ autoCapitalize={uppercase ? "characters" : "none"}
205
+ className={inputClass}
206
+ />
207
+ </span>
216
208
  </Fragment>
217
209
  ))}
218
210
  </BaseOtpField.Root>
@@ -13,6 +13,7 @@ import { mergeRefs } from "../../lib/merge-refs";
13
13
  import { indicator, indicatorBox } from "../../lib/motion";
14
14
  import { useFieldContext } from "../field/Field";
15
15
  import {
16
+ controlLine,
16
17
  controlSize,
17
18
  type ControlSize,
18
19
  type ControlVariant,
@@ -140,12 +141,11 @@ export function SegmentedControl({
140
141
  "forced-colors:after:bg-[Highlight] forced-colors:has-[[data-selected][data-disabled]]:after:bg-[GrayText]",
141
142
  track[variant],
142
143
  controlSize[size],
143
- // 和输入框一样靠边线说状态
144
144
  disabled
145
145
  ? "border-line"
146
146
  : invalid
147
147
  ? "border-danger"
148
- : "border-line-strong focus-within:border-ink",
148
+ : ["border-line-strong", controlLine(variant)],
149
149
  className,
150
150
  )}
151
151
  />
@@ -191,8 +191,6 @@ export function Select(props: SelectProps) {
191
191
  data-invalid={invalid ? "" : undefined}
192
192
  className={cn(
193
193
  controlBox({ variant, invalid, disabled, readOnly: false }),
194
- // 面板开着的时候边线保持聚焦的墨色
195
- !disabled && !invalid && "has-data-popup-open:border-ink",
196
194
  controlSize[size],
197
195
  className,
198
196
  )}
@@ -0,0 +1,340 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type ComponentProps,
8
+ type CSSProperties,
9
+ type KeyboardEvent,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { usePresence } from "../../hooks/usePresence";
13
+ import { cn } from "../../lib/cn";
14
+ import { decor as decorLayer } from "../../lib/decor";
15
+ import { mergeRefs } from "../../lib/merge-refs";
16
+
17
+ export type SplashScreenProps = Omit<ComponentProps<"div">, "children"> & {
18
+ /** 真实进度,0 – 100。不知道就不传:显示不确定进度,不显示数字 */
19
+ value?: number;
20
+ /** 标志:自己的图或 SVG。放在画面偏右的一个盒子里,竖排时居中 */
21
+ logo?: ReactNode;
22
+ /** 标志下面的一行标语 */
23
+ tagline?: ReactNode;
24
+ /** 标语上方那组微标里的微文字。纯氛围,对读屏隐藏 */
25
+ caption?: ReactNode;
26
+ /**
27
+ * 换掉标语上方的整组微标。不传是代码画的一组(小方框、`caption`、方点阵,旁边一枚三角标),
28
+ * 传 `null` 是不要
29
+ */
30
+ decor?: ReactNode;
31
+ /** 读数下面的状态字。默认 `LOADING` */
32
+ status?: ReactNode;
33
+ /** 底图:铺满整块,随进度从模糊变清晰 */
34
+ backdrop?: ReactNode;
35
+ /** 置为 `false` 开始退场:走满、铺满、淡出,结束后卸载。默认 `true` */
36
+ open?: boolean;
37
+ /** 铺满视口并锁住页面(默认);关掉则铺满所在的定位容器 */
38
+ fullscreen?: boolean;
39
+ /** 进度条的可访问名称 */
40
+ label?: string;
41
+ /** 开始淡出、页面露出来的那一刻触发:页面自己的入场从这里起 */
42
+ onReveal?: () => void;
43
+ /** 淡出结束、已经卸载之后触发 */
44
+ onExited?: () => void;
45
+ };
46
+
47
+ /*
48
+ * 进度的补间 0.5s(官网实测)。两个变量一起走:位置用小数的那个,读数用整数的那个。
49
+ * 退场时再加上整层的淡出:等 1.4s(走满 0.5s、铺满 0.6s、停一下)之后淡 1s。
50
+ */
51
+ const progressTransition =
52
+ "[transition:--ef-splash-progress_500ms_var(--ease-exit),--ef-splash-percent_500ms_var(--ease-exit)]";
53
+ const leavingTransition =
54
+ "[transition:--ef-splash-progress_500ms_var(--ease-exit),--ef-splash-percent_500ms_var(--ease-exit),opacity_1s_var(--ease-standard)_1.4s]";
55
+
56
+ /** 三角标:三个朝下的小三角拼成一个倒三角 */
57
+ function Mark() {
58
+ return (
59
+ <svg
60
+ viewBox="0 0 24 21"
61
+ aria-hidden="true"
62
+ className={cn(
63
+ decorLayer,
64
+ "absolute -top-[calc(var(--ef-splash-unit)*2.5)] -left-[calc(var(--ef-splash-unit)*3.375)] h-[calc(var(--ef-splash-unit)*1.3125)] w-[calc(var(--ef-splash-unit)*1.4375)] fill-action cq-portrait:hidden",
65
+ )}
66
+ >
67
+ <path d="M0 0h11.6L5.8 10zM12.4 0H24l-5.8 10zM6.2 11h11.6L12 21z" />
68
+ </svg>
69
+ );
70
+ }
71
+
72
+ /**
73
+ * 代码画的那组微标:带框的加号、微文字、两排方点。至少占官网那张图的高度(4 个单位);
74
+ * 微文字有字号的下限,多出来的往上长。竖排时上面紧挨着进度条的读数,放不下微文字
75
+ */
76
+ function DefaultDecor({ caption }: { caption: ReactNode }) {
77
+ return (
78
+ <div className="flex min-h-[calc(var(--ef-splash-unit)*4)] flex-col items-start justify-between gap-[calc(var(--ef-splash-unit)*0.5)] text-neutral-600">
79
+ <svg
80
+ viewBox="0 0 12 12"
81
+ fill="none"
82
+ stroke="currentColor"
83
+ className="size-[calc(var(--ef-splash-unit)*1.125)]"
84
+ >
85
+ <path d="M.5.5h11v11H.5zM6 3v6M3 6h6" />
86
+ </svg>
87
+ {caption !== undefined && (
88
+ <p className="font-latin text-[length:max(calc(var(--ef-splash-unit)*0.75),var(--text-micro))] leading-[1.2] font-medium tracking-label whitespace-pre-line text-ink-tertiary uppercase cq-portrait:hidden">
89
+ {caption}
90
+ </p>
91
+ )}
92
+ {/* 画成一张图:方点只有半个单位大,一个个排成元素的话,小尺寸下间隔会忽宽忽窄 */}
93
+ <svg
94
+ viewBox="0 0 62 18"
95
+ fill="currentColor"
96
+ className="h-[calc(var(--ef-splash-unit)*1.125)] w-[calc(var(--ef-splash-unit)*3.875)]"
97
+ >
98
+ {[0, 11].flatMap((y) =>
99
+ [0, 11, 22, 33, 44, 55].map((x) => (
100
+ <rect key={`${x}-${y}`} x={x} y={y} width="7" height="7" />
101
+ )),
102
+ )}
103
+ </svg>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ /**
109
+ * 开屏页:照官网首次载入的那一页排的。近黑底,一根贴边的黄色进度条,读数跟着条的末端走;
110
+ * 画面偏右是标志,下面一组微标、一条分隔线和一行标语。就绪后黄色铺满整屏,再整层淡出。
111
+ *
112
+ * 标志和底图是插槽,库里不带任何图。横竖看它所在的那一块自己的宽高比,不看视口;
113
+ * 尺寸跟着那一块的宽度等比缩放,插槽里的东西可以用 `--ef-splash-unit` 跟着缩。
114
+ *
115
+ * 只要一块盖住整页的等待、不要这些排场,用 `Loader`。
116
+ */
117
+ export function SplashScreen({
118
+ value,
119
+ logo,
120
+ tagline,
121
+ caption,
122
+ decor,
123
+ status = "LOADING",
124
+ backdrop,
125
+ open = true,
126
+ fullscreen = true,
127
+ label = "加载中",
128
+ onReveal,
129
+ onExited,
130
+ onKeyDown,
131
+ className,
132
+ style,
133
+ ref,
134
+ ...props
135
+ }: SplashScreenProps) {
136
+ // 页面露出来了没有:开始淡出的那一刻。在那之前这一层一直盖着、锁着
137
+ const [revealed, setRevealed] = useState(false);
138
+ if (open && revealed) setRevealed(false);
139
+
140
+ const handlers = useRef({ onReveal });
141
+ const fired = useRef(false);
142
+ useLayoutEffect(() => {
143
+ handlers.current = { onReveal };
144
+ if (open) fired.current = false;
145
+ });
146
+ const reveal = useCallback(() => {
147
+ if (fired.current) return;
148
+ fired.current = true;
149
+ setRevealed(true);
150
+ handlers.current.onReveal?.();
151
+ }, []);
152
+
153
+ // 退出:淡出的过渡走完后卸载;重新打开立刻挂回来
154
+ const { ref: rootRef, mounted } = usePresence<HTMLDivElement>(open, {
155
+ onExited: () => {
156
+ // 没有过渡可等的环境里等不到"开始淡出":卸载之前补上
157
+ reveal();
158
+ onExited?.();
159
+ },
160
+ });
161
+ const leaving = mounted && !open;
162
+
163
+ // 淡出排在铺满之后,等的是它真的开始的那一刻(延迟走完),不是退场的开头
164
+ useLayoutEffect(() => {
165
+ const node = rootRef.current;
166
+ if (!leaving || !node) return;
167
+ const onStart = (event: TransitionEvent) => {
168
+ if (event.target === node && event.propertyName === "opacity") reveal();
169
+ };
170
+ node.addEventListener("transitionstart", onStart);
171
+ return () => node.removeEventListener("transitionstart", onStart);
172
+ }, [leaving, rootRef, reveal]);
173
+
174
+ /*
175
+ * 整页模式:锁住页面滚动,把焦点收进来。和 Loader 不一样,开始退场时不放开——
176
+ * 黄色铺满的那一段页面还完全被盖着,放开了只会点到看不见的东西。到开始淡出才放
177
+ */
178
+ const blocking = mounted && (open || !revealed);
179
+ useEffect(() => {
180
+ if (!fullscreen || !blocking) return;
181
+ const page = document.documentElement;
182
+ const previousOverflow = page.style.overflow;
183
+ const previousFocus = document.activeElement;
184
+ page.style.overflow = "hidden";
185
+ rootRef.current?.focus({ preventScroll: true });
186
+ return () => {
187
+ page.style.overflow = previousOverflow;
188
+ if (previousFocus instanceof HTMLElement && previousFocus.isConnected) {
189
+ previousFocus.focus({ preventScroll: true });
190
+ }
191
+ };
192
+ }, [fullscreen, blocking, rootRef]);
193
+
194
+ if (!mounted) return null;
195
+
196
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
197
+ onKeyDown?.(event);
198
+ // 焦点留在开屏页上,不让 Tab 走到被盖住的内容里
199
+ if (fullscreen && blocking && event.key === "Tab") event.preventDefault();
200
+ };
201
+
202
+ const indeterminate = value === undefined;
203
+ const current = indeterminate ? 0 : Math.min(Math.max(value, 0), 100);
204
+ // 退场先把条走满:就绪就是百分之百,铺满前的那半秒留给它
205
+ const shown = open ? current : 100;
206
+ const travelling = indeterminate && open;
207
+
208
+ return (
209
+ <div
210
+ tabIndex={-1}
211
+ {...props}
212
+ ref={mergeRefs(rootRef, ref)}
213
+ // 固定的近黑底,不随页面的主题变:做成局部的暗色主题,插槽里照常用语义令牌
214
+ data-theme="dark"
215
+ data-state={open ? "open" : "closing"}
216
+ onKeyDown={handleKeyDown}
217
+ style={
218
+ {
219
+ ...style,
220
+ "--ef-splash-progress": shown,
221
+ "--ef-splash-percent": Math.round(shown),
222
+ } as CSSProperties
223
+ }
224
+ className={cn(
225
+ "overflow-hidden bg-surface text-ink outline-none [container-type:size]",
226
+ fullscreen ? "fixed inset-0 z-(--z-loader)" : "absolute inset-0",
227
+ leaving ? leavingTransition : progressTransition,
228
+ leaving && "opacity-0",
229
+ !blocking && "pointer-events-none",
230
+ className,
231
+ )}
232
+ >
233
+ {/*
234
+ * 一个单位是官网的 1rem:横排是宽度的 1/160,竖排是 1/67.5。
235
+ * 状态字和标语有字号的下限(官网在小屏上会缩到看不清),收成两个变量:
236
+ * 竖排时下面那一摞是从底边往上排的,条要按它们多占的高度往上让
237
+ */}
238
+ <div className="absolute inset-0 [--_status:max(var(--ef-splash-unit),var(--text-xs))] [--_tagline:max(calc(var(--ef-splash-unit)*1.5),var(--text-xs))] [--ef-splash-unit:0.625cqw] cq-portrait:[--ef-splash-unit:1.4815cqw]">
239
+ {backdrop !== undefined && (
240
+ <div
241
+ aria-hidden="true"
242
+ className={cn(
243
+ decorLayer,
244
+ "absolute inset-0 blur-[calc((100_-_var(--ef-splash-progress))*0.08px)] *:size-full [&>img]:object-cover",
245
+ )}
246
+ >
247
+ {backdrop}
248
+ </div>
249
+ )}
250
+
251
+ {/*
252
+ * 纵向位置和官网一样按宽度定(标志在上 27.625 个单位,微标组的分隔线在 63.375),
253
+ * 所以 16:9 以外的比例下这一列不在正中。官网在超宽的屏上会掉出底边:这里放不下时
254
+ * 往上收,标语的底边至少离底 4 个单位。竖排的标志同理,不低于高度的 41.667%
255
+ */}
256
+ {logo !== undefined && (
257
+ <div className="absolute top-[min(calc(var(--ef-splash-unit)*27.625),calc(100%_-_var(--ef-splash-unit)*44.25))] left-[64.4531%] flex h-[calc(var(--ef-splash-unit)*16.75)] w-[calc(var(--ef-splash-unit)*16.5)] items-center justify-center cq-portrait:top-[min(calc(var(--ef-splash-unit)*50),41.667%)] cq-portrait:left-1/2 cq-portrait:h-[calc(var(--ef-splash-unit)*20.9375)] cq-portrait:w-[calc(var(--ef-splash-unit)*19.3125)] cq-portrait:-translate-1/2 [&>:is(img,svg)]:size-full [&>img]:object-contain">
258
+ {logo}
259
+ </div>
260
+ )}
261
+
262
+ <div className="absolute top-[min(calc(var(--ef-splash-unit)*63.375),calc(100%_-_var(--ef-splash-unit)*8.5))] right-[calc(var(--ef-splash-unit)*2)] left-[64.4531%] cq-portrait:top-auto cq-portrait:right-[calc(var(--ef-splash-unit)*4.625)] cq-portrait:bottom-[calc(var(--ef-splash-unit)*5.75)] cq-portrait:left-[calc(var(--ef-splash-unit)*4.625)]">
263
+ {decor === undefined && <Mark />}
264
+ {decor !== null && (
265
+ <div
266
+ aria-hidden="true"
267
+ className={cn(decorLayer, "absolute bottom-full left-0")}
268
+ >
269
+ {decor === undefined ? <DefaultDecor caption={caption} /> : decor}
270
+ </div>
271
+ )}
272
+ <span
273
+ aria-hidden="true"
274
+ className={cn(
275
+ decorLayer,
276
+ "mt-[calc(var(--ef-splash-unit)*1.25)] mb-[calc(var(--ef-splash-unit))] -ml-[calc(var(--ef-splash-unit)*21.625)] block h-0.5 w-[calc(var(--ef-splash-unit)*70)] bg-linear-to-r from-transparent via-neutral-0/10 to-transparent",
277
+ )}
278
+ />
279
+ {tagline !== undefined && (
280
+ <p className="text-[length:var(--_tagline)] leading-[1.35] wrap-anywhere">
281
+ {tagline}
282
+ </p>
283
+ )}
284
+ </div>
285
+
286
+ <div
287
+ role="progressbar"
288
+ aria-label={label}
289
+ aria-valuemin={0}
290
+ aria-valuemax={100}
291
+ aria-valuenow={travelling ? undefined : shown}
292
+ // 高对比模式下底色都被系统换掉:轨道剩一圈线,填充用系统的"选中"色(同 Progress)
293
+ className="absolute top-0 left-0 h-full w-[calc(var(--ef-splash-unit)*1.25)] forced-colors:outline forced-colors:-outline-offset-1 forced-colors:outline-[CanvasText] cq-portrait:top-auto cq-portrait:bottom-[calc(var(--ef-splash-unit)*17.75_+_(var(--_status)_-_var(--ef-splash-unit))*1.25_+_(var(--_tagline)_-_var(--ef-splash-unit)*1.5)*1.35)] cq-portrait:h-[calc(var(--ef-splash-unit)*2.5)] cq-portrait:w-[82.4074%]"
294
+ >
295
+ {travelling ? (
296
+ <span className="absolute inset-0 overflow-hidden">
297
+ <span className="absolute inset-x-0 top-0 h-2/5 animate-indeterminate-y bg-action motion-reduce:translate-y-3/4 forced-colors:bg-[Highlight] cq-portrait:inset-x-auto cq-portrait:inset-y-0 cq-portrait:left-0 cq-portrait:h-auto cq-portrait:w-2/5 cq-portrait:animate-indeterminate cq-portrait:motion-reduce:translate-x-3/4 cq-portrait:motion-reduce:translate-y-0" />
298
+ </span>
299
+ ) : (
300
+ <span className="absolute top-0 left-0 h-[calc(var(--ef-splash-progress)*1%)] w-full bg-action forced-colors:bg-[Highlight] cq-portrait:h-full cq-portrait:w-[calc(var(--ef-splash-progress)*1%)]" />
301
+ )}
302
+
303
+ {/* 读数骑在条的末端上:一条没有高度的基线,数字在线上面,状态字在线下面 */}
304
+ <div
305
+ aria-hidden="true"
306
+ className={cn(
307
+ "absolute top-[calc(var(--ef-splash-progress)*1%)] left-[calc(var(--ef-splash-unit)*3.125)] h-0 whitespace-nowrap cq-portrait:top-auto cq-portrait:-bottom-[calc(var(--ef-splash-unit)*1.75)] cq-portrait:left-[calc(var(--ef-splash-progress)*1%)] cq-portrait:ml-[calc(var(--ef-splash-unit)*0.75)]",
308
+ // 没有数字的时候条的末端不在哪:状态字往下让一点,别贴着上沿
309
+ travelling &&
310
+ "translate-y-[calc(var(--ef-splash-unit)*3)] cq-portrait:translate-y-0",
311
+ )}
312
+ >
313
+ {!indeterminate && (
314
+ <p className="absolute bottom-[calc(var(--ef-splash-unit)*1.625)] left-0 leading-none text-action">
315
+ <span className="mb-[calc(var(--ef-splash-unit)*0.25)] block h-[calc(var(--ef-splash-unit)*1.875)] w-[calc(var(--ef-splash-unit)*0.5)] rounded-[calc(var(--ef-splash-unit)*0.25)] bg-action" />
316
+ {/* 数字是计数器画的:它跟着那个整数变量补间,脚本不用一帧一帧去写 */}
317
+ <span className="text-[length:calc(var(--ef-splash-unit)*4.375)] font-medium tabular-nums [counter-reset:ef-splash_var(--ef-splash-percent)] before:content-[counter(ef-splash)]" />
318
+ <span className="text-[length:calc(var(--ef-splash-unit)*3.25)]">
319
+ %
320
+ </span>
321
+ </p>
322
+ )}
323
+ <p className="absolute -top-[calc(var(--ef-splash-unit)*0.5)] left-0 text-[length:var(--_status)] leading-[1.25] text-ink-tertiary">
324
+ <span className="block h-[calc(var(--ef-splash-unit)*0.5)] w-[calc(var(--ef-splash-unit)*1.25)] border-x-[length:calc(var(--ef-splash-unit)*0.5)] border-current" />
325
+ {status}
326
+ </p>
327
+ </div>
328
+ </div>
329
+ </div>
330
+
331
+ {/* 铺满:等条走满(0.5s)再从左往右盖过去,0.6s,先慢后快(官网实测) */}
332
+ {leaving && (
333
+ <span
334
+ aria-hidden="true"
335
+ className="absolute inset-0 origin-left animate-fill bg-action [animation-delay:500ms] [animation-timing-function:cubic-bezier(1,0,0.7,1)] motion-reduce:hidden forced-colors:bg-[Highlight]"
336
+ />
337
+ )}
338
+ </div>
339
+ );
340
+ }
@@ -0,0 +1 @@
1
+ export { SplashScreen, type SplashScreenProps } from "./SplashScreen";