@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.
- package/NOTICE.md +2 -2
- package/README.md +103 -8
- package/dist/index.d.ts +69 -15
- package/dist/index.js +1104 -239
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/README.md +12 -3
- package/src/components/alert/Alert.tsx +63 -1
- package/src/components/avatar/Avatar.tsx +5 -2
- package/src/components/bracket-title/BracketTitle.tsx +32 -3
- package/src/components/button/Button.tsx +37 -5
- package/src/components/calendar/Calendar.tsx +49 -5
- package/src/components/carousel/Carousel.tsx +2 -1
- package/src/components/checkbox/Checkbox.tsx +1 -0
- package/src/components/checkbox/choice-style.ts +9 -4
- package/src/components/chip/capsule-style.ts +18 -3
- package/src/components/combobox/Combobox.tsx +10 -0
- package/src/components/completion-banner/CompletionBanner.tsx +30 -2
- package/src/components/dash-indicator/DashIndicator.tsx +10 -3
- package/src/components/data-row/DataRow.tsx +2 -1
- package/src/components/date-picker/DateField.tsx +5 -1
- package/src/components/dropdown-menu/MenuCheck.tsx +4 -2
- package/src/components/dropdown-menu/menu-style.ts +13 -3
- package/src/components/empty-state/EmptyState.tsx +5 -0
- package/src/components/field/Field.tsx +39 -7
- package/src/components/file-upload/FileUpload.tsx +78 -19
- package/src/components/icon-button/IconButton.tsx +18 -5
- package/src/components/input/control-box.ts +23 -2
- package/src/components/input/value-chip.ts +3 -2
- package/src/components/loader/Loader.tsx +12 -41
- package/src/components/nav-menu/NavMenu.tsx +3 -2
- package/src/components/otp-input/OtpInput.tsx +8 -2
- package/src/components/page-header/PageHeader.tsx +39 -3
- package/src/components/pagination/Pagination.tsx +2 -0
- package/src/components/progress/Progress.tsx +10 -3
- package/src/components/progress/ProgressRing.tsx +19 -4
- package/src/components/radio/Radio.tsx +2 -0
- package/src/components/rolling-number/RollingNumber.tsx +125 -0
- package/src/components/rolling-number/index.ts +1 -0
- package/src/components/schedule/Schedule.tsx +5 -3
- package/src/components/segmented-control/SegmentedControl.tsx +25 -4
- package/src/components/skeleton/Skeleton.tsx +4 -1
- package/src/components/slider/Slider.tsx +21 -4
- package/src/components/sparkline/Sparkline.tsx +29 -5
- package/src/components/spinner/Spinner.tsx +6 -2
- package/src/components/stepper/Stepper.tsx +4 -0
- package/src/components/steps/Steps.tsx +18 -6
- package/src/components/switch/Switch.tsx +26 -2
- package/src/components/table/Table.tsx +33 -9
- package/src/components/tabs/Tabs.tsx +55 -9
- package/src/components/tag-input/TagInput.tsx +82 -43
- package/src/components/textarea/Textarea.tsx +2 -1
- package/src/components/timeline/Timeline.tsx +4 -3
- package/src/components/toc/Toc.tsx +89 -7
- package/src/components/toolbar/Toolbar.tsx +24 -0
- package/src/hooks/README.md +8 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAddedKeys.ts +26 -0
- package/src/hooks/useFlip.ts +211 -0
- package/src/hooks/useIndicator.ts +135 -0
- package/src/hooks/useLingering.ts +122 -0
- package/src/hooks/usePresence.ts +100 -0
- package/src/hooks/useReducedMotion.ts +26 -0
- package/src/hooks/useTheme.ts +44 -4
- package/src/index.ts +1 -0
- package/src/lib/README.md +2 -1
- package/src/lib/cn.ts +1 -0
- package/src/lib/inline-style.ts +12 -0
- package/src/lib/motion.ts +75 -0
- package/src/styles/theme.css +38 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type RefObject,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { afterMotion } from "./usePresence";
|
|
10
|
+
|
|
11
|
+
export type LingeringEntry = {
|
|
12
|
+
key: string;
|
|
13
|
+
/** 已经从列表里拿掉了,留着把退场走完 */
|
|
14
|
+
leaving: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const same = (a: readonly string[], b: readonly string[]) =>
|
|
18
|
+
a.length === b.length && a.every((key, index) => key === b[index]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 新的一份:现在的那几项按现在的顺序,走着的插回它原来挨着的那一项后面。
|
|
22
|
+
* `keep` 是这一次新走的要不要留。
|
|
23
|
+
*/
|
|
24
|
+
function merge(
|
|
25
|
+
before: readonly LingeringEntry[],
|
|
26
|
+
keys: readonly string[],
|
|
27
|
+
keep: boolean,
|
|
28
|
+
): LingeringEntry[] {
|
|
29
|
+
const now = new Set(keys);
|
|
30
|
+
const next: LingeringEntry[] = keys.map((key) => ({ key, leaving: false }));
|
|
31
|
+
// 顺着旧的那一份走一遍:anchor 是眼下最后一个还在新的一份里的
|
|
32
|
+
let anchor: string | null = null;
|
|
33
|
+
for (const entry of before) {
|
|
34
|
+
if (now.has(entry.key)) {
|
|
35
|
+
anchor = entry.key;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
// 早先就在走的接着走;这一次才走的看 keep
|
|
39
|
+
if (!entry.leaving && !keep) continue;
|
|
40
|
+
const at =
|
|
41
|
+
anchor === null ? -1 : next.findIndex((item) => item.key === anchor);
|
|
42
|
+
next.splice(at + 1, 0, { key: entry.key, leaving: true });
|
|
43
|
+
anchor = entry.key;
|
|
44
|
+
}
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 列表里被拿掉的项先留着:`keys` 里少了一项,它不当场消失,而是带着 `leaving`
|
|
50
|
+
* 留在原来的位置上,等它身上的退场走完才真的拿掉(等法和 `usePresence` 是同一套:
|
|
51
|
+
* 看元素上正在跑的动效,一个都没有就当场拿掉——测试环境里东西删了就是删了)。
|
|
52
|
+
*
|
|
53
|
+
* 用法:`entries` 按顺序渲染成 `ref` 那个容器的直接子元素,走着的那几项带上
|
|
54
|
+
* `data-leaving`(退场的样式挂在它上面),并且对键盘和读屏隐藏。钩子按这个标记找元素。
|
|
55
|
+
*
|
|
56
|
+
* - 这一次变化里同时有加进来的(整批换掉、"只收一个"换了一个):新走的不留,照旧直接换。
|
|
57
|
+
* - 走到一半又加回来:它不再是走着的,留在页面上的还是同一个元素。
|
|
58
|
+
* - `enabled` 为假时不留新走的(项太多的时候)。
|
|
59
|
+
*/
|
|
60
|
+
export function useLingering<T extends Element = HTMLElement>(
|
|
61
|
+
keys: readonly string[],
|
|
62
|
+
enabled = true,
|
|
63
|
+
): { ref: RefObject<T | null>; entries: readonly LingeringEntry[] } {
|
|
64
|
+
const ref = useRef<T>(null);
|
|
65
|
+
const [state, setState] = useState(() => ({
|
|
66
|
+
keys,
|
|
67
|
+
entries: merge([], keys, false),
|
|
68
|
+
}));
|
|
69
|
+
let { entries } = state;
|
|
70
|
+
if (!same(state.keys, keys)) {
|
|
71
|
+
const known = new Set(state.keys);
|
|
72
|
+
const grew = keys.some((key) => !known.has(key));
|
|
73
|
+
entries = merge(state.entries, keys, enabled && !grew);
|
|
74
|
+
setState({ keys, entries });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const drop = useCallback((key: string) => {
|
|
78
|
+
setState((current) => ({
|
|
79
|
+
keys: current.keys,
|
|
80
|
+
entries: current.entries.filter(
|
|
81
|
+
(entry) => !(entry.leaving && entry.key === key),
|
|
82
|
+
),
|
|
83
|
+
}));
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
// 每一个走着的等一次:它身上的退场走完就拿掉
|
|
87
|
+
const waits = useRef(new Map<string, () => void>());
|
|
88
|
+
useLayoutEffect(() => {
|
|
89
|
+
const pending = waits.current;
|
|
90
|
+
const leaving = entries
|
|
91
|
+
.filter((entry) => entry.leaving)
|
|
92
|
+
.map((entry) => entry.key);
|
|
93
|
+
// 不再是走着的了(走完了,或者又加回来了):不等了
|
|
94
|
+
for (const [key, cancel] of pending) {
|
|
95
|
+
if (leaving.includes(key)) continue;
|
|
96
|
+
cancel();
|
|
97
|
+
pending.delete(key);
|
|
98
|
+
}
|
|
99
|
+
if (leaving.length === 0) return;
|
|
100
|
+
// 走着的那几项在页面上的顺序,就是它们在 entries 里的顺序
|
|
101
|
+
const nodes = [...(ref.current?.children ?? [])].filter((node) =>
|
|
102
|
+
node.hasAttribute("data-leaving"),
|
|
103
|
+
);
|
|
104
|
+
leaving.forEach((key, index) => {
|
|
105
|
+
if (pending.has(key)) return;
|
|
106
|
+
pending.set(
|
|
107
|
+
key,
|
|
108
|
+
afterMotion(nodes[index] ?? null, () => drop(key)),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
}, [entries, drop]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const pending = waits.current;
|
|
115
|
+
return () => {
|
|
116
|
+
for (const cancel of pending.values()) cancel();
|
|
117
|
+
pending.clear();
|
|
118
|
+
};
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
return { ref, entries };
|
|
122
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
type Options<T extends Element> = {
|
|
4
|
+
/**
|
|
5
|
+
* 开始退场、还没去看有没有动效在跑之前调。退场的起点要用脚本摆的在这里摆
|
|
6
|
+
* (提示条量自己的高度)。返回的函数在退场被打断(中途又打开)时把摆上去的撤掉
|
|
7
|
+
*/
|
|
8
|
+
onLeave?: (node: T) => void | (() => void);
|
|
9
|
+
/** 退完、已经卸载之后 */
|
|
10
|
+
onExited?: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type Presence<T extends Element> = {
|
|
14
|
+
/** 给会动的那个元素:退场时等的是它身上的过渡和动画 */
|
|
15
|
+
ref: RefObject<T | null>;
|
|
16
|
+
/** 该不该渲染 */
|
|
17
|
+
mounted: boolean;
|
|
18
|
+
/** 正在退场:`open` 已经是假的了,元素还留着 */
|
|
19
|
+
leaving: boolean;
|
|
20
|
+
/** 是后来才出现的。一开始就在的是 `false`——进场的起点只给后来出现的 */
|
|
21
|
+
entered: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** 循环的动画(加载指示那一类)等不到头,不等 */
|
|
25
|
+
function ends(animation: Animation) {
|
|
26
|
+
return animation.effect?.getComputedTiming().iterations !== Infinity;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 等这个元素上**正在跑的**、有尽头的过渡和动画走完,再调 `done`;一个都没有
|
|
31
|
+
* (没有写退场的样式、测试环境里没有动效)就当场调。返回的函数取消这一次等待。
|
|
32
|
+
*/
|
|
33
|
+
export function afterMotion(
|
|
34
|
+
node: Element | null,
|
|
35
|
+
done: () => void,
|
|
36
|
+
): () => void {
|
|
37
|
+
let live = true;
|
|
38
|
+
const finish = () => {
|
|
39
|
+
if (live) done();
|
|
40
|
+
};
|
|
41
|
+
// 读这一下,浏览器会先把这次的样式变化算完:刚起的过渡已经在里面了。
|
|
42
|
+
// `finished` 要现在就拿住——过渡被取消之后再去拿,拿到的是一个永远不落定的新承诺
|
|
43
|
+
const running =
|
|
44
|
+
typeof node?.getAnimations === "function"
|
|
45
|
+
? node.getAnimations().filter(ends)
|
|
46
|
+
: [];
|
|
47
|
+
if (running.length === 0) finish();
|
|
48
|
+
else {
|
|
49
|
+
void Promise.allSettled(
|
|
50
|
+
running.map((animation) => animation.finished),
|
|
51
|
+
).then(finish);
|
|
52
|
+
}
|
|
53
|
+
return () => {
|
|
54
|
+
live = false;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 退场后再卸载:`open` 变成假之后元素先留着,等它身上的动效走完才卸载。
|
|
60
|
+
*
|
|
61
|
+
* 等的是这个元素上**正在跑的**过渡和动画,不另抄一份时长;一个都没有
|
|
62
|
+
* (没有写退场的样式、测试环境里没有动效)就当场卸载。退场途中又打开:不卸载,
|
|
63
|
+
* 过渡自己折回去。
|
|
64
|
+
*/
|
|
65
|
+
export function usePresence<T extends Element = HTMLElement>(
|
|
66
|
+
open: boolean,
|
|
67
|
+
{ onLeave, onExited }: Options<T> = {},
|
|
68
|
+
): Presence<T> {
|
|
69
|
+
const ref = useRef<T>(null);
|
|
70
|
+
const [mounted, setMounted] = useState(open);
|
|
71
|
+
const [entered, setEntered] = useState(false);
|
|
72
|
+
// 打开:立刻挂上
|
|
73
|
+
if (open && !mounted) {
|
|
74
|
+
setMounted(true);
|
|
75
|
+
setEntered(true);
|
|
76
|
+
}
|
|
77
|
+
const leaving = !open && mounted;
|
|
78
|
+
|
|
79
|
+
const handlers = useRef({ onLeave, onExited });
|
|
80
|
+
useLayoutEffect(() => {
|
|
81
|
+
handlers.current = { onLeave, onExited };
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
useLayoutEffect(() => {
|
|
85
|
+
if (!leaving) return;
|
|
86
|
+
const node = ref.current;
|
|
87
|
+
const undo = node ? handlers.current.onLeave?.(node) : undefined;
|
|
88
|
+
const cancel = afterMotion(node, () => {
|
|
89
|
+
setMounted(false);
|
|
90
|
+
handlers.current.onExited?.();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return () => {
|
|
94
|
+
cancel();
|
|
95
|
+
undo?.();
|
|
96
|
+
};
|
|
97
|
+
}, [leaving]);
|
|
98
|
+
|
|
99
|
+
return { ref, mounted, leaving, entered };
|
|
100
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
const QUERY = "(prefers-reduced-motion: reduce)";
|
|
4
|
+
|
|
5
|
+
function subscribe(notify: () => void) {
|
|
6
|
+
if (typeof window.matchMedia !== "function") return () => {};
|
|
7
|
+
const media = window.matchMedia(QUERY);
|
|
8
|
+
media.addEventListener("change", notify);
|
|
9
|
+
return () => media.removeEventListener("change", notify);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function read() {
|
|
13
|
+
return (
|
|
14
|
+
typeof window.matchMedia === "function" && window.matchMedia(QUERY).matches
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 系统开了"减少动态效果"时返回 true,设置变了会跟着变。
|
|
20
|
+
*
|
|
21
|
+
* CSS 的动画和过渡不用它:`theme.css` 末尾的全局规则已经统一降级了。
|
|
22
|
+
* 只有脚本驱动的动效(一帧一帧自己算的)才要看它——那条规则管不到脚本。
|
|
23
|
+
*/
|
|
24
|
+
export function useReducedMotion(): boolean {
|
|
25
|
+
return useSyncExternalStore(subscribe, read, () => false);
|
|
26
|
+
}
|
package/src/hooks/useTheme.ts
CHANGED
|
@@ -7,11 +7,14 @@ type Snapshot = { theme: ThemePreference; resolvedTheme: Theme };
|
|
|
7
7
|
|
|
8
8
|
const STORAGE_KEY = "ef-theme";
|
|
9
9
|
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
10
|
+
const REDUCE_QUERY = "(prefers-reduced-motion: reduce)";
|
|
10
11
|
|
|
11
12
|
const serverSnapshot: Snapshot = { theme: "light", resolvedTheme: "light" };
|
|
12
13
|
const listeners = new Set<() => void>();
|
|
13
14
|
let snapshot: Snapshot | null = null;
|
|
14
15
|
let stopWatchingSystem: (() => void) | null = null;
|
|
16
|
+
/** 正在走的那一次切换(视图过渡):它走完才撤掉 <html> 上的标记 */
|
|
17
|
+
let switching: unknown = null;
|
|
15
18
|
|
|
16
19
|
function isPreference(value: unknown): value is ThemePreference {
|
|
17
20
|
return value === "light" || value === "dark" || value === "system";
|
|
@@ -42,10 +45,45 @@ function getSnapshot(): Snapshot {
|
|
|
42
45
|
return snapshot;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* 换主题。亮暗真的变了、浏览器有视图过渡、系统没开"减少动态效果"时,放在视图过渡里换:
|
|
50
|
+
* 整页交叉淡化一次,而不是带换色过渡的慢慢过去、别的硬切,参差不齐。否则当场换。
|
|
51
|
+
*
|
|
52
|
+
* 走视图过渡的时候是晚一帧才换的——浏览器要先把旧的样子拍下来。
|
|
53
|
+
* 期间 <html> 上标着 data-theme-switching:淡化的时长挂在它下面(theme.css),
|
|
54
|
+
* 使用方自己的视图过渡不受影响。局部主题那几块新旧一个样,看不出变化。
|
|
55
|
+
*/
|
|
45
56
|
function commit(theme: ThemePreference) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
const next: Snapshot = { theme, resolvedTheme: resolve(theme) };
|
|
58
|
+
const element = document.documentElement;
|
|
59
|
+
const apply = () => {
|
|
60
|
+
snapshot = next;
|
|
61
|
+
element.dataset.theme = next.resolvedTheme;
|
|
62
|
+
for (const listener of listeners) listener();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
element.dataset.theme === next.resolvedTheme ||
|
|
67
|
+
typeof document.startViewTransition !== "function" ||
|
|
68
|
+
// 全局的降级规则管不到视图过渡的那几层:这里自己看
|
|
69
|
+
window.matchMedia?.(REDUCE_QUERY).matches
|
|
70
|
+
) {
|
|
71
|
+
apply();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
element.dataset.themeSwitching = "";
|
|
76
|
+
const transition = document.startViewTransition(apply);
|
|
77
|
+
switching = transition;
|
|
78
|
+
const done = () => {
|
|
79
|
+
// 被下一次切换打断了的:标记留给后来的那一次撤
|
|
80
|
+
if (switching !== transition) return;
|
|
81
|
+
switching = null;
|
|
82
|
+
delete element.dataset.themeSwitching;
|
|
83
|
+
};
|
|
84
|
+
transition.finished.then(done, done);
|
|
85
|
+
// 被打断时这个承诺是被拒绝的:不算错
|
|
86
|
+
transition.ready.catch(() => {});
|
|
49
87
|
}
|
|
50
88
|
|
|
51
89
|
function subscribe(listener: () => void) {
|
|
@@ -83,7 +121,8 @@ function setTheme(theme: ThemePreference) {
|
|
|
83
121
|
*
|
|
84
122
|
* - `theme`:用户的选择,可以是 `"system"`;
|
|
85
123
|
* - `resolvedTheme`:实际生效的 `"light"` 或 `"dark"`;
|
|
86
|
-
* - `setTheme`:切换并记到 `localStorage
|
|
124
|
+
* - `setTheme`:切换并记到 `localStorage`。亮暗变了的时候整页交叉淡化一次
|
|
125
|
+
* (浏览器支持视图过渡、系统没开"减少动态效果"时;否则直接切)。
|
|
87
126
|
*/
|
|
88
127
|
export function useTheme(): Snapshot & {
|
|
89
128
|
setTheme: (theme: ThemePreference) => void;
|
|
@@ -94,6 +133,7 @@ export function useTheme(): Snapshot & {
|
|
|
94
133
|
() => serverSnapshot,
|
|
95
134
|
);
|
|
96
135
|
|
|
136
|
+
// 挂上的时候把记着的主题同步到 <html>:这不是切换,直接写,不淡
|
|
97
137
|
useEffect(() => {
|
|
98
138
|
document.documentElement.dataset.theme = resolvedTheme;
|
|
99
139
|
}, [resolvedTheme]);
|
package/src/index.ts
CHANGED
|
@@ -51,6 +51,7 @@ export * from "./components/progress";
|
|
|
51
51
|
export * from "./components/radio";
|
|
52
52
|
export * from "./components/rec-indicator";
|
|
53
53
|
export * from "./components/registration-strip";
|
|
54
|
+
export * from "./components/rolling-number";
|
|
54
55
|
export * from "./components/schedule";
|
|
55
56
|
export * from "./components/scroll-area";
|
|
56
57
|
export * from "./components/scroll-hint";
|
package/src/lib/README.md
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
| `cn.ts` | `cn()`:合并类名(`clsx` + `tailwind-merge`)。里面登记了 [theme.css](../styles/theme.css) 的自定义令牌名,否则 `text-micro` 会被当成颜色、与 `text-ink` 互相覆盖 |
|
|
8
8
|
| `focus-ring.ts` | `focusRing`、`focusRingInset`、`focusRingWithin`:全库统一的焦点环类名 |
|
|
9
9
|
| `merge-refs.ts` | `mergeRefs()`:组件自己要用节点、同时还得把它交给使用方时,把几个 ref 合成一个 |
|
|
10
|
+
| `inline-style.ts` | `dropEmptyStyle()`:往元素上临时写过内联样式、又都撤掉之后,把空下来的 `style` 属性拿掉。浏览器是懒着同步这个属性的,直接删会落空、回头又冒出一个空的 |
|
|
10
11
|
| `defined.ts` | `defined()`:去掉值是 `undefined` 的属性。把控件的属性合并到使用方给的元素上(`render`)之前用,免得把那个元素自己写的盖掉 |
|
|
11
12
|
| `decor.ts` | `decor`:装饰层共用的类 |
|
|
12
|
-
| `motion.ts` |
|
|
13
|
+
| `motion.ts` | 几处共用的动效类名。`enterFromSide`:浮层面板进场的方向位移——按基元给的 `data-side`,起点往触发元素那一侧偏 `--motion-shift`;`collapse` / `collapseEnter` / `collapseInner`:按高度收放;`fadeInFast`:小件的淡入;`indicator` / `indicatorBox`:画在容器 `::after` 上的选中指示,位置来自 `useIndicator`;`flipBox` / `flipLeaving`:补位(`useFlip`)的那一排,和里面走的那一项。见 [动效](../../../../docs/design/foundations/motion.md#补的几条约定) |
|
|
13
14
|
| `date.ts` | 日期的纯函数:加减日和月、比较、把两天排成先后、夹到范围内、月历的六行七列。日期一律是 `YYYY-MM-DD` 的字符串,全部按 UTC 算,不碰本地时区。`Calendar`、`DatePicker`、`Schedule` 共用,不对外导出 |
|
|
14
15
|
| `file.ts` | 文件的纯函数:按 `accept` 认类型(扩展名、MIME、`image/*` 通配)、把字节数写成 `1.2 MB`、取扩展名、判断是不是同一个文件。`FileUpload` 用,不对外导出 |
|
|
15
16
|
|
package/src/lib/cn.ts
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 往元素上临时写过内联样式、又都撤掉之后调:原来没有 `style` 属性的,
|
|
3
|
+
* 不留一个空的在上面。
|
|
4
|
+
*
|
|
5
|
+
* 先问一句"有没有这个属性"不是多余的:浏览器是懒着把内联样式同步成属性的,
|
|
6
|
+
* 不问就去删,删的那一下落空,回头它又冒出来一个空的 `style=""`。
|
|
7
|
+
*/
|
|
8
|
+
export function dropEmptyStyle(node: HTMLElement) {
|
|
9
|
+
if (node.style.length === 0 && node.hasAttribute("style")) {
|
|
10
|
+
node.removeAttribute("style");
|
|
11
|
+
}
|
|
12
|
+
}
|
package/src/lib/motion.ts
CHANGED
|
@@ -13,3 +13,78 @@ export const enterFromSide = [
|
|
|
13
13
|
"data-starting-style:data-[side=inline-end]:-translate-x-(--motion-shift)",
|
|
14
14
|
"data-starting-style:data-[side=inline-start]:translate-x-(--motion-shift)",
|
|
15
15
|
].join(" ");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 按高度收放,不量高度:外面这一层是只有一行的网格,行高在 `0fr` 和 `1fr` 之间过渡;
|
|
19
|
+
* 里面再套一层 `collapseInner`,裁掉还放不下的那一截。表格的明细行、字段下面的错误文字
|
|
20
|
+
* 都这么收放——内容后来变高了也跟着走。
|
|
21
|
+
*
|
|
22
|
+
* 展开 300ms、收起 200ms。收起由 `data-leaving` 触发,配 `usePresence`:收完再卸载。
|
|
23
|
+
*/
|
|
24
|
+
export const collapse = [
|
|
25
|
+
"grid grid-rows-[1fr]",
|
|
26
|
+
"transition-[grid-template-rows] duration-(--duration-base) ease-standard",
|
|
27
|
+
"data-leaving:grid-rows-[0fr] data-leaving:duration-(--duration-fast)",
|
|
28
|
+
].join(" ");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 展开的起点,写在 `@starting-style` 里:元素一挂上就从 0 长出来,展开到一半又收起时
|
|
32
|
+
* 过渡从半路折回去。只给后来才出现的加(`usePresence` 的 `entered`)——一开始就展开着的,
|
|
33
|
+
* 不该在载入时播一遍。不支持它的旧浏览器只是没有展开的过程。
|
|
34
|
+
*/
|
|
35
|
+
export const collapseEnter = "starting:grid-rows-[0fr]";
|
|
36
|
+
|
|
37
|
+
/** 只裁纵向:横向照旧,里面的东西该多宽还是多宽 */
|
|
38
|
+
export const collapseInner = "min-h-0 min-w-0 overflow-y-clip";
|
|
39
|
+
|
|
40
|
+
/** 小件的淡入,200ms:切过来的页签面板、新加的标签、新选的文件 */
|
|
41
|
+
export const fadeInFast =
|
|
42
|
+
"animate-fade-in [animation-duration:var(--duration-fast)]";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 选中指示:画在容器的 `::after` 上的一块东西,压在容器的底色之上、各项之下。
|
|
46
|
+
* 位置和尺寸来自 `useIndicator` 写在容器上的变量;换了一项时滑过去(200ms,
|
|
47
|
+
* 和各项换字色同一拍),没有当前项时原地淡出。
|
|
48
|
+
*
|
|
49
|
+
* 容器上没有 `data-indicator`(量不到)时不画——各项照旧自己画选中态,
|
|
50
|
+
* 所以给各项的选中底加一个 `in-data-indicator:` 的变体把它让出来。
|
|
51
|
+
* 这里只管定位和过渡:颜色、圆角由用的地方给,几何用 `indicatorBox` 或者自己写。
|
|
52
|
+
*/
|
|
53
|
+
export const indicator = [
|
|
54
|
+
"relative isolate",
|
|
55
|
+
"after:pointer-events-none after:absolute after:top-0 after:left-0 after:-z-1",
|
|
56
|
+
"after:hidden data-indicator:after:block",
|
|
57
|
+
"after:opacity-0 data-[indicator=on]:after:opacity-100",
|
|
58
|
+
"after:transition-[translate,width,height,opacity] after:duration-(--duration-fast) after:ease-standard",
|
|
59
|
+
// 没有当前项的时候只留淡入淡出:下一次亮起来是原地出现,不是从上一个位置滑来
|
|
60
|
+
"data-[indicator=off]:after:transition-[opacity]",
|
|
61
|
+
"data-indicator-instant:after:transition-none",
|
|
62
|
+
].join(" ");
|
|
63
|
+
|
|
64
|
+
/** 指示盖住当前项的整个盒子:分段选择的墨块、胶囊页签的底 */
|
|
65
|
+
export const indicatorBox = [
|
|
66
|
+
"after:h-(--indicator-h) after:w-(--indicator-w)",
|
|
67
|
+
"after:translate-x-(--indicator-x) after:translate-y-(--indicator-y)",
|
|
68
|
+
].join(" ");
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 补位(`useFlip`):一排里有一项走了,其余的从旧位置滑到新位置。
|
|
72
|
+
*
|
|
73
|
+
* `flipBox` 给那一排——走的那一项以它为参照钉在原处,并且压在别的项下面。
|
|
74
|
+
* 少了一行它就矮了:高度由钩子量着收(200ms),这期间还在滑的项不露到它外面去;
|
|
75
|
+
* 各行靠上排,撑着的那一段空在底下。
|
|
76
|
+
* `flipLeaving` 给会走的每一项:带上 `data-leaving` 之后脱离排版(位置由钩子给)、
|
|
77
|
+
* 原地淡出、点不到。
|
|
78
|
+
*
|
|
79
|
+
* 过渡由用的地方自己写:各项的 `translate`(滑回原位)、走的那一项的 `opacity`,
|
|
80
|
+
* 都是 200ms——这几项多半本来就带着换色的过渡,得写进同一份清单里。
|
|
81
|
+
*/
|
|
82
|
+
export const flipBox = [
|
|
83
|
+
"relative isolate content-start overflow-y-clip",
|
|
84
|
+
"transition-[min-height] duration-(--duration-fast) ease-standard",
|
|
85
|
+
].join(" ");
|
|
86
|
+
|
|
87
|
+
export const flipLeaving = [
|
|
88
|
+
"data-leaving:pointer-events-none data-leaving:absolute data-leaving:-z-1",
|
|
89
|
+
"data-leaving:opacity-0",
|
|
90
|
+
].join(" ");
|
package/src/styles/theme.css
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* endfield-ui
|
|
2
|
+
* endfield-ui 设计令牌
|
|
3
3
|
*
|
|
4
4
|
* 用法:
|
|
5
5
|
* @import "tailwindcss";
|
|
6
|
-
* @import "
|
|
6
|
+
* @import "@endfield-ui/react/theme.css";
|
|
7
7
|
*
|
|
8
8
|
* 结构:
|
|
9
9
|
* 1. 语义变量 随主题变化;亮色在 :root 与 [data-theme="light"],
|
|
@@ -377,6 +377,7 @@
|
|
|
377
377
|
--animate-pulse: ef-pulse 2s ease-in-out infinite;
|
|
378
378
|
--animate-bracket-in: ef-bracket-in 200ms ease-out both;
|
|
379
379
|
--animate-shift-in: ef-shift-in 300ms ease-out both;
|
|
380
|
+
--animate-wipe-in: ef-wipe-in 300ms ease-out both;
|
|
380
381
|
|
|
381
382
|
/* 三次短促闪烁后点亮(官网实测的关键帧节奏) */
|
|
382
383
|
@keyframes ef-flicker {
|
|
@@ -547,6 +548,23 @@
|
|
|
547
548
|
translate: var(--shift-x, 0) var(--shift-y, 0);
|
|
548
549
|
}
|
|
549
550
|
}
|
|
551
|
+
|
|
552
|
+
/*
|
|
553
|
+
* 擦入:从左往右露出来。两头都要写——"没有裁切"和 inset() 之间插不了值。
|
|
554
|
+
* 有东西画到盒子外面一点的(小型面积图的折线有半个线宽在外面),用 --wipe-bleed
|
|
555
|
+
* 把另外几边往外让,不然走完之后那一圈是被裁掉的
|
|
556
|
+
*/
|
|
557
|
+
@keyframes ef-wipe-in {
|
|
558
|
+
from {
|
|
559
|
+
clip-path: inset(
|
|
560
|
+
calc(0px - var(--wipe-bleed, 0px)) 100%
|
|
561
|
+
calc(0px - var(--wipe-bleed, 0px)) calc(0px - var(--wipe-bleed, 0px))
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
to {
|
|
565
|
+
clip-path: inset(calc(0px - var(--wipe-bleed, 0px)));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
550
568
|
}
|
|
551
569
|
|
|
552
570
|
/* ------------------------------------------------------------------ */
|
|
@@ -592,6 +610,24 @@
|
|
|
592
610
|
--color-info: var(--ef-info);
|
|
593
611
|
}
|
|
594
612
|
|
|
613
|
+
/* ------------------------------------------------------------------ */
|
|
614
|
+
/* 主题切换 */
|
|
615
|
+
/* ------------------------------------------------------------------ */
|
|
616
|
+
|
|
617
|
+
/*
|
|
618
|
+
* useTheme 切亮暗时整页交叉淡化一次(浏览器支持视图过渡时)。切的那一下它在 <html> 上标
|
|
619
|
+
* data-theme-switching,这里把根的那几层换成"颜色、透明度"那一档的时长。
|
|
620
|
+
* 只在这个标记下生效:使用方自己的视图过渡不受影响。三层要一起给——只改新旧两层的话,
|
|
621
|
+
* 组那一层还是浏览器默认的 250ms,整个过渡要到那时候才算完。
|
|
622
|
+
* "减少动态效果"不在这里管:开了的时候 useTheme 根本不走视图过渡
|
|
623
|
+
*/
|
|
624
|
+
:root[data-theme-switching]::view-transition-group(root),
|
|
625
|
+
:root[data-theme-switching]::view-transition-old(root),
|
|
626
|
+
:root[data-theme-switching]::view-transition-new(root) {
|
|
627
|
+
animation-duration: var(--duration-fast);
|
|
628
|
+
animation-timing-function: var(--ease-standard);
|
|
629
|
+
}
|
|
630
|
+
|
|
595
631
|
/* ------------------------------------------------------------------ */
|
|
596
632
|
/* 减少动态效果 */
|
|
597
633
|
/* ------------------------------------------------------------------ */
|