@endfield-ui/react 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +27 -0
- package/README.md +818 -2
- package/dist/index.d.ts +3445 -0
- package/dist/index.js +8706 -0
- package/dist/styles.css +2 -0
- package/package.json +83 -3
- package/src/components/README.md +179 -0
- package/src/components/accordion/Accordion.tsx +168 -0
- package/src/components/accordion/index.ts +8 -0
- package/src/components/alert/Alert.tsx +106 -0
- package/src/components/alert/index.ts +1 -0
- package/src/components/avatar/Avatar.tsx +92 -0
- package/src/components/avatar/AvatarSwitcher.tsx +261 -0
- package/src/components/avatar/index.ts +8 -0
- package/src/components/back-to-top/BackToTop.tsx +124 -0
- package/src/components/back-to-top/index.ts +5 -0
- package/src/components/badge/Badge.tsx +75 -0
- package/src/components/badge/index.ts +1 -0
- package/src/components/bracket-title/BracketTitle.tsx +40 -0
- package/src/components/bracket-title/index.ts +1 -0
- package/src/components/breadcrumb/Breadcrumb.tsx +161 -0
- package/src/components/breadcrumb/index.ts +6 -0
- package/src/components/button/Button.tsx +357 -0
- package/src/components/button/ButtonGroup.tsx +55 -0
- package/src/components/button/index.ts +13 -0
- package/src/components/calendar/Calendar.tsx +443 -0
- package/src/components/calendar/index.ts +7 -0
- package/src/components/carousel/Carousel.tsx +252 -0
- package/src/components/carousel/index.ts +7 -0
- package/src/components/carousel/snap-track.ts +74 -0
- package/src/components/checkbox/Checkbox.tsx +94 -0
- package/src/components/checkbox/choice-style.ts +30 -0
- package/src/components/checkbox/index.ts +1 -0
- package/src/components/chip/FilterChip.tsx +68 -0
- package/src/components/chip/ResourceChip.tsx +63 -0
- package/src/components/chip/capsule-style.ts +22 -0
- package/src/components/chip/index.ts +6 -0
- package/src/components/combobox/Combobox.tsx +450 -0
- package/src/components/combobox/index.ts +10 -0
- package/src/components/completion-banner/CompletionBanner.tsx +72 -0
- package/src/components/completion-banner/index.ts +4 -0
- package/src/components/context-menu/ContextMenu.tsx +70 -0
- package/src/components/context-menu/index.ts +5 -0
- package/src/components/corner-brackets/CornerBrackets.tsx +43 -0
- package/src/components/corner-brackets/index.ts +5 -0
- package/src/components/countdown/Countdown.tsx +145 -0
- package/src/components/countdown/index.ts +6 -0
- package/src/components/dash-indicator/DashIndicator.tsx +44 -0
- package/src/components/dash-indicator/index.ts +1 -0
- package/src/components/data-row/DataRow.tsx +282 -0
- package/src/components/data-row/index.ts +8 -0
- package/src/components/date-picker/DateField.tsx +159 -0
- package/src/components/date-picker/DatePicker.tsx +182 -0
- package/src/components/date-picker/DateRangePicker.tsx +203 -0
- package/src/components/date-picker/index.ts +7 -0
- package/src/components/dialog/Dialog.tsx +203 -0
- package/src/components/dialog/index.ts +7 -0
- package/src/components/dialog/initial-focus.ts +19 -0
- package/src/components/dialog/overlay-style.ts +28 -0
- package/src/components/drawer/Drawer.tsx +213 -0
- package/src/components/drawer/index.ts +6 -0
- package/src/components/dropdown-menu/DropdownMenu.tsx +427 -0
- package/src/components/dropdown-menu/MenuCheck.tsx +44 -0
- package/src/components/dropdown-menu/index.ts +22 -0
- package/src/components/dropdown-menu/menu-style.ts +47 -0
- package/src/components/empty-state/EmptyState.tsx +79 -0
- package/src/components/empty-state/index.ts +1 -0
- package/src/components/field/Field.tsx +203 -0
- package/src/components/field/index.ts +6 -0
- package/src/components/file-upload/FileItem.tsx +201 -0
- package/src/components/file-upload/FileUpload.tsx +425 -0
- package/src/components/file-upload/index.ts +9 -0
- package/src/components/flyout-bar/FlyoutBar.tsx +158 -0
- package/src/components/flyout-bar/index.ts +7 -0
- package/src/components/ghost-text/GhostText.tsx +47 -0
- package/src/components/ghost-text/index.ts +5 -0
- package/src/components/hatch/Hatch.tsx +35 -0
- package/src/components/hatch/index.ts +1 -0
- package/src/components/hazard-stripe/HazardStripe.tsx +35 -0
- package/src/components/hazard-stripe/index.ts +5 -0
- package/src/components/hover-card/HoverCard.tsx +101 -0
- package/src/components/hover-card/index.ts +6 -0
- package/src/components/icon-button/IconButton.tsx +95 -0
- package/src/components/icon-button/index.ts +6 -0
- package/src/components/image-viewer/ImageViewer.tsx +427 -0
- package/src/components/image-viewer/index.ts +7 -0
- package/src/components/input/Input.tsx +109 -0
- package/src/components/input/control-box.ts +71 -0
- package/src/components/input/index.ts +6 -0
- package/src/components/input/value-chip.ts +34 -0
- package/src/components/item-slot/ItemGrid.tsx +207 -0
- package/src/components/item-slot/ItemSlot.tsx +284 -0
- package/src/components/item-slot/index.ts +7 -0
- package/src/components/kbd/Kbd.tsx +33 -0
- package/src/components/kbd/index.ts +1 -0
- package/src/components/link-element/LinkElement.tsx +25 -0
- package/src/components/list/List.tsx +229 -0
- package/src/components/list/band-style.ts +18 -0
- package/src/components/list/index.ts +8 -0
- package/src/components/loader/Loader.tsx +154 -0
- package/src/components/loader/index.ts +1 -0
- package/src/components/marquee/Marquee.tsx +96 -0
- package/src/components/marquee/index.ts +1 -0
- package/src/components/media-card/MediaCard.tsx +139 -0
- package/src/components/media-card/index.ts +5 -0
- package/src/components/nav-action/NavAction.tsx +142 -0
- package/src/components/nav-action/index.ts +5 -0
- package/src/components/nav-menu/NavMenu.tsx +288 -0
- package/src/components/nav-menu/index.ts +6 -0
- package/src/components/navigator/Navigator.tsx +136 -0
- package/src/components/navigator/index.ts +5 -0
- package/src/components/otp-input/OtpInput.tsx +211 -0
- package/src/components/otp-input/index.ts +7 -0
- package/src/components/page-header/PageHeader.tsx +182 -0
- package/src/components/page-header/index.ts +6 -0
- package/src/components/pagination/Pagination.tsx +181 -0
- package/src/components/pagination/index.ts +5 -0
- package/src/components/panel/Panel.tsx +114 -0
- package/src/components/panel/index.ts +13 -0
- package/src/components/play-button/PlayButton.tsx +92 -0
- package/src/components/play-button/index.ts +7 -0
- package/src/components/popover/Popover.tsx +97 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/panel-style.ts +15 -0
- package/src/components/progress/Progress.tsx +133 -0
- package/src/components/progress/ProgressRing.tsx +123 -0
- package/src/components/progress/index.ts +2 -0
- package/src/components/radio/Radio.tsx +182 -0
- package/src/components/radio/index.ts +7 -0
- package/src/components/rec-indicator/RecIndicator.tsx +47 -0
- package/src/components/rec-indicator/index.ts +1 -0
- package/src/components/registration-strip/RegistrationStrip.tsx +51 -0
- package/src/components/registration-strip/index.ts +5 -0
- package/src/components/schedule/Schedule.tsx +457 -0
- package/src/components/schedule/index.ts +9 -0
- package/src/components/scroll-area/ScrollArea.tsx +137 -0
- package/src/components/scroll-area/index.ts +5 -0
- package/src/components/scroll-hint/ScrollHint.tsx +78 -0
- package/src/components/scroll-hint/index.ts +5 -0
- package/src/components/section-title/SectionTitle.tsx +202 -0
- package/src/components/section-title/index.ts +5 -0
- package/src/components/segmented-control/SegmentedControl.tsx +220 -0
- package/src/components/segmented-control/index.ts +8 -0
- package/src/components/select/Select.tsx +390 -0
- package/src/components/select/index.ts +15 -0
- package/src/components/side-rail/SideRail.tsx +317 -0
- package/src/components/side-rail/SideRailSub.tsx +298 -0
- package/src/components/side-rail/index.ts +14 -0
- package/src/components/skeleton/Skeleton.tsx +70 -0
- package/src/components/skeleton/index.ts +1 -0
- package/src/components/slider/Slider.tsx +219 -0
- package/src/components/slider/index.ts +8 -0
- package/src/components/sparkline/Sparkline.tsx +122 -0
- package/src/components/sparkline/index.ts +6 -0
- package/src/components/spinner/Spinner.tsx +64 -0
- package/src/components/spinner/index.ts +1 -0
- package/src/components/stat/Stat.tsx +79 -0
- package/src/components/stat/index.ts +1 -0
- package/src/components/stepper/Stepper.tsx +316 -0
- package/src/components/stepper/index.ts +1 -0
- package/src/components/steps/Steps.tsx +302 -0
- package/src/components/steps/index.ts +10 -0
- package/src/components/switch/Switch.tsx +120 -0
- package/src/components/switch/index.ts +1 -0
- package/src/components/table/Table.tsx +560 -0
- package/src/components/table/index.ts +20 -0
- package/src/components/tabs/Tabs.tsx +313 -0
- package/src/components/tabs/index.ts +12 -0
- package/src/components/tag/Tag.tsx +66 -0
- package/src/components/tag/TagPair.tsx +34 -0
- package/src/components/tag/index.ts +2 -0
- package/src/components/tag-input/TagInput.tsx +502 -0
- package/src/components/tag-input/index.ts +8 -0
- package/src/components/term/Term.tsx +53 -0
- package/src/components/term/index.ts +1 -0
- package/src/components/textarea/Textarea.tsx +99 -0
- package/src/components/textarea/index.ts +1 -0
- package/src/components/texture/Texture.tsx +45 -0
- package/src/components/texture/index.ts +1 -0
- package/src/components/tick-ring/TickRing.tsx +77 -0
- package/src/components/tick-ring/index.ts +1 -0
- package/src/components/timeline/Timeline.tsx +84 -0
- package/src/components/timeline/index.ts +7 -0
- package/src/components/toast/Toast.tsx +257 -0
- package/src/components/toast/index.ts +10 -0
- package/src/components/toc/Toc.tsx +263 -0
- package/src/components/toc/index.ts +7 -0
- package/src/components/toolbar/Toolbar.tsx +389 -0
- package/src/components/toolbar/index.ts +17 -0
- package/src/components/tooltip/Tooltip.tsx +175 -0
- package/src/components/tooltip/index.ts +8 -0
- package/src/components/top-bar/TopBar.tsx +47 -0
- package/src/components/top-bar/index.ts +1 -0
- package/src/components/viewfinder/Viewfinder.tsx +97 -0
- package/src/components/viewfinder/index.ts +6 -0
- package/src/hooks/README.md +14 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useControllableState.ts +30 -0
- package/src/hooks/useInView.ts +44 -0
- package/src/hooks/useOverflowing.ts +28 -0
- package/src/hooks/usePortalScope.ts +68 -0
- package/src/hooks/useScrollPosition.ts +57 -0
- package/src/hooks/useTheme.ts +102 -0
- package/src/icons/ArrowCorner.tsx +7 -0
- package/src/icons/ArrowLeft.tsx +6 -0
- package/src/icons/ArrowRight.tsx +6 -0
- package/src/icons/CalendarIcon.tsx +10 -0
- package/src/icons/Check.tsx +4 -0
- package/src/icons/ChevronDown.tsx +3 -0
- package/src/icons/ChevronLeft.tsx +6 -0
- package/src/icons/ChevronRight.tsx +6 -0
- package/src/icons/Close.tsx +3 -0
- package/src/icons/Crosshair.tsx +7 -0
- package/src/icons/Lock.tsx +6 -0
- package/src/icons/Menu.tsx +4 -0
- package/src/icons/Minus.tsx +4 -0
- package/src/icons/Plus.tsx +3 -0
- package/src/icons/README.md +31 -0
- package/src/icons/StatusDanger.tsx +7 -0
- package/src/icons/StatusInfo.tsx +7 -0
- package/src/icons/StatusSuccess.tsx +10 -0
- package/src/icons/StatusWarning.tsx +7 -0
- package/src/icons/TriangleRight.tsx +7 -0
- package/src/icons/Upload.tsx +7 -0
- package/src/icons/createIcon.tsx +34 -0
- package/src/icons/index.ts +21 -0
- package/src/index.ts +87 -0
- package/src/lib/README.md +17 -0
- package/src/lib/cn.ts +34 -0
- package/src/lib/date.ts +125 -0
- package/src/lib/decor.ts +6 -0
- package/src/lib/defined.ts +9 -0
- package/src/lib/file.ts +61 -0
- package/src/lib/focus-ring.ts +11 -0
- package/src/lib/merge-refs.ts +11 -0
- package/src/styles/README.md +70 -0
- package/src/styles/index.css +25 -0
- package/src/styles/theme.css +568 -0
- package/src/styles/utilities.css +241 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
type ComponentProps,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { cn } from "../../lib/cn";
|
|
9
|
+
|
|
10
|
+
export type CountdownSize = "sm" | "md";
|
|
11
|
+
export type CountdownState = "ample" | "urgent" | "expired";
|
|
12
|
+
|
|
13
|
+
export type CountdownProps = Omit<ComponentProps<"span">, "children"> & {
|
|
14
|
+
/** 截止时间:`Date`、时间戳或能被 `new Date()` 解析的字符串 */
|
|
15
|
+
to: Date | number | string;
|
|
16
|
+
/** 剩余时间不多于这个毫秒数时进入紧迫态,默认 24 小时 */
|
|
17
|
+
urgentWithin?: number;
|
|
18
|
+
/** 到期后显示的文字 */
|
|
19
|
+
expiredLabel?: ReactNode;
|
|
20
|
+
/** 到期的那一刻调用一次;挂载时就已过期则不调用 */
|
|
21
|
+
onExpire?: () => void;
|
|
22
|
+
/** 超过一天时"天"的写法,如 `3天 04:12:09` */
|
|
23
|
+
dayUnit?: string;
|
|
24
|
+
/** 给读屏的静态截止时间。默认是"截止 + 本地格式的日期时间" */
|
|
25
|
+
deadlineLabel?: ReactNode;
|
|
26
|
+
size?: CountdownSize;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const DAY = 86_400_000;
|
|
30
|
+
|
|
31
|
+
const sizeClass: Record<CountdownSize, string> = {
|
|
32
|
+
sm: "h-5 gap-1 px-2 text-xs",
|
|
33
|
+
md: "h-7 gap-1.5 px-3 text-sm",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* 状态色用文字档的语义令牌作底、ink-inverse 作字:两个主题下都 ≥ 4.5:1。
|
|
38
|
+
* 角色色里的 alert 红作底时,墨字白字都不够。
|
|
39
|
+
*/
|
|
40
|
+
const stateClass: Record<CountdownState, string> = {
|
|
41
|
+
ample: "bg-success text-ink-inverse",
|
|
42
|
+
urgent: "bg-danger text-ink-inverse",
|
|
43
|
+
expired: "bg-surface-muted text-ink-secondary",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function pad(value: number) {
|
|
47
|
+
return String(value).padStart(2, "0");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function format(remaining: number, dayUnit: string) {
|
|
51
|
+
const seconds = Math.ceil(remaining / 1000);
|
|
52
|
+
const days = Math.floor(seconds / 86_400);
|
|
53
|
+
const clock = [
|
|
54
|
+
Math.floor((seconds % 86_400) / 3600),
|
|
55
|
+
Math.floor((seconds % 3600) / 60),
|
|
56
|
+
seconds % 60,
|
|
57
|
+
]
|
|
58
|
+
.map(pad)
|
|
59
|
+
.join(":");
|
|
60
|
+
return days > 0 ? `${days}${dayUnit} ${clock}` : clock;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 倒计时:充裕时是绿色胶囊,紧迫时变红并多一个 `!`。
|
|
65
|
+
* 不会每秒播报——读屏拿到的是静态的截止时间,走到这里时才读当前剩余。
|
|
66
|
+
*/
|
|
67
|
+
export function Countdown({
|
|
68
|
+
to,
|
|
69
|
+
urgentWithin = DAY,
|
|
70
|
+
expiredLabel = "已结束",
|
|
71
|
+
onExpire,
|
|
72
|
+
dayUnit = "天",
|
|
73
|
+
deadlineLabel,
|
|
74
|
+
size = "md",
|
|
75
|
+
className,
|
|
76
|
+
...props
|
|
77
|
+
}: CountdownProps) {
|
|
78
|
+
const target = new Date(to).getTime();
|
|
79
|
+
const [now, setNow] = useState(() => Date.now());
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setNow(Date.now());
|
|
83
|
+
if (!(target > Date.now())) return;
|
|
84
|
+
|
|
85
|
+
const timer = setInterval(() => {
|
|
86
|
+
const current = Date.now();
|
|
87
|
+
setNow(current);
|
|
88
|
+
if (current >= target) clearInterval(timer);
|
|
89
|
+
}, 1000);
|
|
90
|
+
return () => clearInterval(timer);
|
|
91
|
+
}, [target]);
|
|
92
|
+
|
|
93
|
+
const remaining = Number.isNaN(target) ? 0 : Math.max(0, target - now);
|
|
94
|
+
const state: CountdownState =
|
|
95
|
+
remaining === 0
|
|
96
|
+
? "expired"
|
|
97
|
+
: remaining <= urgentWithin
|
|
98
|
+
? "urgent"
|
|
99
|
+
: "ample";
|
|
100
|
+
|
|
101
|
+
const onExpireRef = useRef(onExpire);
|
|
102
|
+
const previous = useRef(state);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
onExpireRef.current = onExpire;
|
|
105
|
+
});
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (state === "expired" && previous.current !== "expired") {
|
|
108
|
+
onExpireRef.current?.();
|
|
109
|
+
}
|
|
110
|
+
previous.current = state;
|
|
111
|
+
}, [state]);
|
|
112
|
+
|
|
113
|
+
const valid = !Number.isNaN(target);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<span
|
|
117
|
+
{...props}
|
|
118
|
+
data-state={state}
|
|
119
|
+
className={cn(
|
|
120
|
+
"inline-flex shrink-0 items-center rounded-full font-tech leading-none font-bold whitespace-nowrap tabular-nums",
|
|
121
|
+
sizeClass[size],
|
|
122
|
+
stateClass[state],
|
|
123
|
+
className,
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{state === "urgent" && <span aria-hidden="true">!</span>}
|
|
127
|
+
{/* 本地时间在服务端和浏览器里可能不同,这两处不做水合校验 */}
|
|
128
|
+
<span role="timer" aria-live="off" suppressHydrationWarning>
|
|
129
|
+
{state === "expired" ? expiredLabel : format(remaining, dayUnit)}
|
|
130
|
+
</span>
|
|
131
|
+
{valid && (
|
|
132
|
+
<span className="sr-only" suppressHydrationWarning>
|
|
133
|
+
{deadlineLabel ?? (
|
|
134
|
+
<>
|
|
135
|
+
截止{" "}
|
|
136
|
+
<time dateTime={new Date(target).toISOString()}>
|
|
137
|
+
{new Date(target).toLocaleString()}
|
|
138
|
+
</time>
|
|
139
|
+
</>
|
|
140
|
+
)}
|
|
141
|
+
</span>
|
|
142
|
+
)}
|
|
143
|
+
</span>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type DashIndicatorProps = Omit<ComponentProps<"span">, "children"> & {
|
|
5
|
+
/** 一共几项 */
|
|
6
|
+
count: number;
|
|
7
|
+
/** 当前是第几项,从 0 起 */
|
|
8
|
+
index: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 进度短横:一排短横线表示"共几项、在第几项"。只做指示,不可点击;
|
|
13
|
+
* 要能翻动,旁边配一个 `Navigator` 或一对翻页钮。
|
|
14
|
+
*/
|
|
15
|
+
export function DashIndicator({
|
|
16
|
+
count,
|
|
17
|
+
index,
|
|
18
|
+
className,
|
|
19
|
+
...props
|
|
20
|
+
}: DashIndicatorProps) {
|
|
21
|
+
const total = Math.max(0, Math.floor(count));
|
|
22
|
+
const current = Math.min(Math.max(Math.floor(index), 0), total - 1);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<span
|
|
26
|
+
role="img"
|
|
27
|
+
aria-label={`第 ${current + 1} 项,共 ${total} 项`}
|
|
28
|
+
{...props}
|
|
29
|
+
className={cn("inline-flex items-center gap-1", className)}
|
|
30
|
+
>
|
|
31
|
+
{Array.from({ length: total }, (_, position) => (
|
|
32
|
+
<span
|
|
33
|
+
key={position}
|
|
34
|
+
data-current={position === current ? "" : undefined}
|
|
35
|
+
className={cn(
|
|
36
|
+
"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",
|
|
39
|
+
)}
|
|
40
|
+
/>
|
|
41
|
+
))}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DashIndicator, type DashIndicatorProps } from "./DashIndicator";
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
type ComponentProps,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { cn } from "../../lib/cn";
|
|
9
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
10
|
+
import { bandCanvas, bandGap, bandRow } from "../list/band-style";
|
|
11
|
+
import { Sparkline } from "../sparkline/Sparkline";
|
|
12
|
+
|
|
13
|
+
export type DataRowColumns = {
|
|
14
|
+
/** 名称那一列的列名 */
|
|
15
|
+
name: ReactNode;
|
|
16
|
+
/** 走势列的列名。不给就没有这一列 */
|
|
17
|
+
trend?: ReactNode;
|
|
18
|
+
/** 当前值那一列的列名 */
|
|
19
|
+
value: ReactNode;
|
|
20
|
+
/** 参考值列的列名。不给就没有这一列 */
|
|
21
|
+
reference?: ReactNode;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type DataRowListProps = Omit<ComponentProps<"div">, "children"> & {
|
|
25
|
+
/** 这张表的名称,读屏会读出来 */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** 各列的列名;有没有走势列、参考值列也由它决定 */
|
|
28
|
+
columns: DataRowColumns;
|
|
29
|
+
/** `DataRow` */
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type Layout = { trend: boolean; reference: boolean };
|
|
34
|
+
|
|
35
|
+
const LayoutContext = createContext<Layout>({ trend: false, reference: false });
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* 列的模板。容器变窄时先收起走势列(@lg 以下),再收起参考值列(@sm 以下);
|
|
39
|
+
* 对应的单元格用 hidden 收起,模板跟着少一列。
|
|
40
|
+
*/
|
|
41
|
+
const NARROW = "grid-cols-[minmax(0,1fr)_auto]";
|
|
42
|
+
const columnsClass = (layout: Layout) =>
|
|
43
|
+
cn(
|
|
44
|
+
NARROW,
|
|
45
|
+
layout.reference && "@sm:grid-cols-[minmax(0,1fr)_auto_auto]",
|
|
46
|
+
layout.trend &&
|
|
47
|
+
(layout.reference
|
|
48
|
+
? "@lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto_auto]"
|
|
49
|
+
: "@lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto]"),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const trendCell = "hidden @lg:block";
|
|
53
|
+
const referenceCell = "hidden @sm:block";
|
|
54
|
+
|
|
55
|
+
/* 每一行(连同列头)都是一个子网格:对齐到表格的那一组列 */
|
|
56
|
+
const rowGrid = "col-span-full grid grid-cols-subgrid items-center gap-x-4";
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* 列名之间的短竖线画在单元格左边的缝里。截断写在里面那一层上:
|
|
60
|
+
* 写在单元格自己身上的话,overflow 会把竖线一起裁掉。
|
|
61
|
+
*/
|
|
62
|
+
const headerCell =
|
|
63
|
+
"relative min-w-0 not-first:before:absolute not-first:before:top-1/2 not-first:before:-left-2 not-first:before:h-3 not-first:before:w-px not-first:before:-translate-y-1/2 not-first:before:bg-line-strong not-first:before:content-['']";
|
|
64
|
+
|
|
65
|
+
function HeaderCell({
|
|
66
|
+
className,
|
|
67
|
+
children,
|
|
68
|
+
}: {
|
|
69
|
+
className?: string;
|
|
70
|
+
children: ReactNode;
|
|
71
|
+
}) {
|
|
72
|
+
return (
|
|
73
|
+
<span role="columnheader" className={cn(headerCell, className)}>
|
|
74
|
+
<span className="block truncate">{children}</span>
|
|
75
|
+
</span>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 数据行带:浅色画布上一条条深色的行,上面一条胶囊形的列头。
|
|
81
|
+
* 语义是一张表格;里面只放 `DataRow`。
|
|
82
|
+
*/
|
|
83
|
+
export function DataRowList({
|
|
84
|
+
label,
|
|
85
|
+
columns,
|
|
86
|
+
className,
|
|
87
|
+
children,
|
|
88
|
+
...props
|
|
89
|
+
}: DataRowListProps) {
|
|
90
|
+
const layout: Layout = {
|
|
91
|
+
trend: columns.trend !== undefined,
|
|
92
|
+
reference: columns.reference !== undefined,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
{...props}
|
|
98
|
+
className={cn("@container text-ink", bandCanvas, className)}
|
|
99
|
+
>
|
|
100
|
+
<div
|
|
101
|
+
role="table"
|
|
102
|
+
aria-label={label}
|
|
103
|
+
className={cn("grid", bandGap, columnsClass(layout))}
|
|
104
|
+
>
|
|
105
|
+
<div
|
|
106
|
+
role="row"
|
|
107
|
+
className={cn(
|
|
108
|
+
rowGrid,
|
|
109
|
+
// 透明的边线:数据行有一圈 1px 的线,列头也占上这 1px,两边的文字才对得齐
|
|
110
|
+
"min-h-6 rounded-full border border-transparent bg-surface-muted pr-3 pl-4 text-xs",
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
<HeaderCell>{columns.name}</HeaderCell>
|
|
114
|
+
{layout.trend && (
|
|
115
|
+
<HeaderCell className={trendCell}>{columns.trend}</HeaderCell>
|
|
116
|
+
)}
|
|
117
|
+
<HeaderCell className="text-right">{columns.value}</HeaderCell>
|
|
118
|
+
{layout.reference && (
|
|
119
|
+
<HeaderCell className={cn(referenceCell, "text-right")}>
|
|
120
|
+
{columns.reference}
|
|
121
|
+
</HeaderCell>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
<LayoutContext value={layout}>{children}</LayoutContext>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type DataRowTone = "neutral" | "info" | "accent" | "danger";
|
|
131
|
+
|
|
132
|
+
export type DataRowProps = Omit<ComponentProps<"div">, "children"> & {
|
|
133
|
+
/** 这一行的名称 */
|
|
134
|
+
name: ReactNode;
|
|
135
|
+
/**
|
|
136
|
+
* 左缘的类目色条:一个 CSS 颜色值,通常传令牌变量(`var(--color-special)`)。
|
|
137
|
+
* 不要用黄色——黄色是"选中"和"收藏"
|
|
138
|
+
*/
|
|
139
|
+
categoryColor?: string;
|
|
140
|
+
/** 收藏。只传它是一个静态的标记;同时传 `onFavoriteChange` 才是一个能点的切换按钮 */
|
|
141
|
+
favorite?: boolean;
|
|
142
|
+
onFavoriteChange?: (favorite: boolean) => void;
|
|
143
|
+
/** 收藏按钮的可访问名称,默认"收藏" */
|
|
144
|
+
favoriteLabel?: string;
|
|
145
|
+
/** 走势:一串数值,画成行内的小型面积图。表头没有走势列时不显示 */
|
|
146
|
+
series?: readonly number[];
|
|
147
|
+
/** 当前值。正负号连同数值一起传:颜色之外还要有符号 */
|
|
148
|
+
value: ReactNode;
|
|
149
|
+
/**
|
|
150
|
+
* 当前值的含义:
|
|
151
|
+
* - `info` 产出、正向(蓝);
|
|
152
|
+
* - `accent` 消耗、关注(黄);
|
|
153
|
+
* - `danger` 超支、异常(红),走势图也跟着变红;
|
|
154
|
+
* - `neutral` 不表态,默认。
|
|
155
|
+
*/
|
|
156
|
+
tone?: DataRowTone;
|
|
157
|
+
/** 参考值(理论值、上限)。表头没有参考值列时不显示 */
|
|
158
|
+
reference?: ReactNode;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/* 行是局部暗色主题:这几档文字色按深色底取值,对比度都在 4.5:1 以上 */
|
|
162
|
+
const toneClass: Record<DataRowTone, string> = {
|
|
163
|
+
neutral: "text-ink",
|
|
164
|
+
info: "text-info",
|
|
165
|
+
accent: "text-accent-ink",
|
|
166
|
+
danger: "text-danger",
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const favoriteDot = "block size-4 rounded-full";
|
|
170
|
+
const favoriteOn = "bg-action";
|
|
171
|
+
/* 未收藏是一个空心的圆环:形状也不同,不只靠颜色 */
|
|
172
|
+
const favoriteOff = "border-2 border-ink-tertiary";
|
|
173
|
+
|
|
174
|
+
/** 数据行带里的一行。放在 `DataRowList` 里。 */
|
|
175
|
+
export function DataRow({
|
|
176
|
+
name,
|
|
177
|
+
categoryColor,
|
|
178
|
+
favorite,
|
|
179
|
+
onFavoriteChange,
|
|
180
|
+
favoriteLabel = "收藏",
|
|
181
|
+
series,
|
|
182
|
+
value,
|
|
183
|
+
tone = "neutral",
|
|
184
|
+
reference,
|
|
185
|
+
className,
|
|
186
|
+
style,
|
|
187
|
+
...props
|
|
188
|
+
}: DataRowProps) {
|
|
189
|
+
const layout = useContext(LayoutContext);
|
|
190
|
+
const hasFavorite = favorite !== undefined || onFavoriteChange !== undefined;
|
|
191
|
+
const dotClass = cn(favoriteDot, favorite ? favoriteOn : favoriteOff);
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
role="row"
|
|
196
|
+
// 固定的深色:行里的文字、焦点环都按深色底取值
|
|
197
|
+
data-theme="dark"
|
|
198
|
+
data-tone={tone}
|
|
199
|
+
data-favorite={favorite ? "" : undefined}
|
|
200
|
+
{...props}
|
|
201
|
+
style={
|
|
202
|
+
categoryColor
|
|
203
|
+
? ({
|
|
204
|
+
"--data-row-category": categoryColor,
|
|
205
|
+
...style,
|
|
206
|
+
} as CSSProperties)
|
|
207
|
+
: style
|
|
208
|
+
}
|
|
209
|
+
className={cn(rowGrid, bandRow, "relative min-h-11 pr-3 pl-4", className)}
|
|
210
|
+
>
|
|
211
|
+
{categoryColor && (
|
|
212
|
+
<span
|
|
213
|
+
aria-hidden="true"
|
|
214
|
+
data-category=""
|
|
215
|
+
className="absolute inset-y-0 left-0 w-1 bg-(--data-row-category)"
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
<div role="rowheader" className="flex min-w-0 items-center gap-2.5">
|
|
220
|
+
{hasFavorite &&
|
|
221
|
+
(onFavoriteChange ? (
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
aria-label={favoriteLabel}
|
|
225
|
+
aria-pressed={favorite ?? false}
|
|
226
|
+
onClick={() => onFavoriteChange(!favorite)}
|
|
227
|
+
// 圆只有 16px,点击区用伪元素向外补到 36px
|
|
228
|
+
className={cn(
|
|
229
|
+
"relative shrink-0 rounded-full after:absolute after:-inset-2.5 after:content-['']",
|
|
230
|
+
focusRing,
|
|
231
|
+
)}
|
|
232
|
+
>
|
|
233
|
+
<span className={dotClass} />
|
|
234
|
+
</button>
|
|
235
|
+
) : (
|
|
236
|
+
<span aria-hidden="true" className={cn(dotClass, "shrink-0")} />
|
|
237
|
+
))}
|
|
238
|
+
<span className="min-w-0 truncate">
|
|
239
|
+
{name}
|
|
240
|
+
{favorite && !onFavoriteChange && (
|
|
241
|
+
<span className="sr-only">(已收藏)</span>
|
|
242
|
+
)}
|
|
243
|
+
</span>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{layout.trend && (
|
|
247
|
+
<div role="cell" className={cn(trendCell, "h-8")}>
|
|
248
|
+
{series && (
|
|
249
|
+
<Sparkline
|
|
250
|
+
data={series}
|
|
251
|
+
// 量值图表归蓝;只有这一行异常时换成红
|
|
252
|
+
tone={tone === "danger" ? "danger" : "info"}
|
|
253
|
+
className="h-full"
|
|
254
|
+
/>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
<div
|
|
260
|
+
role="cell"
|
|
261
|
+
className={cn(
|
|
262
|
+
"text-right font-tech font-bold whitespace-nowrap tabular-nums",
|
|
263
|
+
toneClass[tone],
|
|
264
|
+
)}
|
|
265
|
+
>
|
|
266
|
+
{value}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{layout.reference && (
|
|
270
|
+
<div
|
|
271
|
+
role="cell"
|
|
272
|
+
className={cn(
|
|
273
|
+
referenceCell,
|
|
274
|
+
"text-right font-tech whitespace-nowrap text-ink-secondary tabular-nums",
|
|
275
|
+
)}
|
|
276
|
+
>
|
|
277
|
+
{reference}
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Popover as BasePopover } from "@base-ui/react/popover";
|
|
2
|
+
import { useRef, type ReactNode, type Ref } from "react";
|
|
3
|
+
import { usePortalScope } from "../../hooks/usePortalScope";
|
|
4
|
+
import { CalendarIcon } from "../../icons/CalendarIcon";
|
|
5
|
+
import { StatusDanger } from "../../icons/StatusDanger";
|
|
6
|
+
import { cn } from "../../lib/cn";
|
|
7
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
8
|
+
import { menuPanel } from "../dropdown-menu/menu-style";
|
|
9
|
+
import {
|
|
10
|
+
controlBox,
|
|
11
|
+
controlIconSize,
|
|
12
|
+
controlSize,
|
|
13
|
+
type ControlSize,
|
|
14
|
+
type ControlVariant,
|
|
15
|
+
} from "../input/control-box";
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* 日期选择和日期范围共用的那层壳:输入框的外框 + 一个按钮 + 一块气泡面板。
|
|
19
|
+
* 不对外导出。面板里放什么(月历、底下那一行)由两个控件各自给。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** 和日期块、微标行同一种写法:`2026.10.09` */
|
|
23
|
+
export const dottedDate = (date: string) => date.replaceAll("-", ".");
|
|
24
|
+
|
|
25
|
+
/** 面板底下那一行里的文字钮 */
|
|
26
|
+
export const dateFooterButton = cn(
|
|
27
|
+
"px-2 py-1 text-sm font-medium text-ink underline underline-offset-4 hover:text-ink-secondary",
|
|
28
|
+
"disabled:cursor-not-allowed disabled:text-ink-disabled disabled:no-underline",
|
|
29
|
+
focusRing,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export type DateFieldProps = {
|
|
33
|
+
/** 外框里显示的值;没选时传 `null`,显示 `placeholder` */
|
|
34
|
+
display: ReactNode | null;
|
|
35
|
+
placeholder: ReactNode;
|
|
36
|
+
/** 随表单提交用的隐藏字段 */
|
|
37
|
+
fields?: ReactNode;
|
|
38
|
+
variant: ControlVariant;
|
|
39
|
+
size: ControlSize;
|
|
40
|
+
invalid: boolean;
|
|
41
|
+
disabled: boolean;
|
|
42
|
+
required: boolean;
|
|
43
|
+
open: boolean;
|
|
44
|
+
onOpenChange: (open: boolean) => void;
|
|
45
|
+
/** 面板的可访问名称 */
|
|
46
|
+
panelLabel: string;
|
|
47
|
+
id?: string;
|
|
48
|
+
"aria-label"?: string;
|
|
49
|
+
"aria-describedby"?: string;
|
|
50
|
+
/** 给外框 */
|
|
51
|
+
className?: string;
|
|
52
|
+
/** 给触发按钮 */
|
|
53
|
+
ref?: Ref<HTMLButtonElement>;
|
|
54
|
+
/** 面板里的东西 */
|
|
55
|
+
children: ReactNode;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function DateField({
|
|
59
|
+
display,
|
|
60
|
+
placeholder,
|
|
61
|
+
fields,
|
|
62
|
+
variant,
|
|
63
|
+
size,
|
|
64
|
+
invalid,
|
|
65
|
+
disabled,
|
|
66
|
+
required,
|
|
67
|
+
open,
|
|
68
|
+
onOpenChange,
|
|
69
|
+
panelLabel,
|
|
70
|
+
id,
|
|
71
|
+
"aria-label": ariaLabel,
|
|
72
|
+
"aria-describedby": describedBy,
|
|
73
|
+
className,
|
|
74
|
+
ref,
|
|
75
|
+
children,
|
|
76
|
+
}: DateFieldProps) {
|
|
77
|
+
const { anchorRef, portalRef } = usePortalScope();
|
|
78
|
+
const boxRef = useRef<HTMLDivElement>(null);
|
|
79
|
+
const popupRef = useRef<HTMLDivElement>(null);
|
|
80
|
+
const iconSize = controlIconSize[size];
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<BasePopover.Root open={open} onOpenChange={(next) => onOpenChange(next)}>
|
|
84
|
+
<div
|
|
85
|
+
ref={(node) => {
|
|
86
|
+
boxRef.current = node;
|
|
87
|
+
anchorRef(node);
|
|
88
|
+
}}
|
|
89
|
+
data-variant={variant}
|
|
90
|
+
data-size={size}
|
|
91
|
+
data-invalid={invalid ? "" : undefined}
|
|
92
|
+
className={cn(
|
|
93
|
+
controlBox({ variant, invalid, disabled, readOnly: false }),
|
|
94
|
+
// 面板开着的时候边线保持聚焦的墨色
|
|
95
|
+
!disabled && !invalid && "has-data-popup-open:border-ink",
|
|
96
|
+
controlSize[size],
|
|
97
|
+
className,
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
<BasePopover.Trigger
|
|
101
|
+
ref={ref}
|
|
102
|
+
id={id}
|
|
103
|
+
disabled={disabled}
|
|
104
|
+
aria-label={ariaLabel}
|
|
105
|
+
aria-invalid={invalid || undefined}
|
|
106
|
+
aria-required={required || undefined}
|
|
107
|
+
aria-describedby={describedBy}
|
|
108
|
+
className="flex h-full min-w-0 flex-1 cursor-default items-center gap-2 px-3 text-left outline-none disabled:cursor-not-allowed"
|
|
109
|
+
>
|
|
110
|
+
{display === null ? (
|
|
111
|
+
<span className="min-w-0 flex-1 truncate text-ink-tertiary">
|
|
112
|
+
{placeholder}
|
|
113
|
+
</span>
|
|
114
|
+
) : (
|
|
115
|
+
<span className="min-w-0 flex-1 truncate font-tech tabular-nums">
|
|
116
|
+
{display}
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
{invalid && (
|
|
120
|
+
<StatusDanger size={iconSize} className="shrink-0 text-danger" />
|
|
121
|
+
)}
|
|
122
|
+
<CalendarIcon
|
|
123
|
+
size={iconSize}
|
|
124
|
+
className={cn(
|
|
125
|
+
"shrink-0",
|
|
126
|
+
disabled ? "text-ink-disabled" : "text-ink-secondary",
|
|
127
|
+
)}
|
|
128
|
+
/>
|
|
129
|
+
</BasePopover.Trigger>
|
|
130
|
+
{fields}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<BasePopover.Portal ref={portalRef}>
|
|
134
|
+
<BasePopover.Positioner
|
|
135
|
+
// 面板对着外框落位,不是对着里面的按钮:否则差出一条边线的宽度
|
|
136
|
+
anchor={boxRef}
|
|
137
|
+
side="bottom"
|
|
138
|
+
align="start"
|
|
139
|
+
sideOffset={4}
|
|
140
|
+
className="z-(--z-overlay) outline-none"
|
|
141
|
+
>
|
|
142
|
+
<BasePopover.Popup
|
|
143
|
+
ref={popupRef}
|
|
144
|
+
aria-label={panelLabel}
|
|
145
|
+
// 打开时焦点落在月历里能被 Tab 停到的那一天:选中的,没选时是今天
|
|
146
|
+
initialFocus={() =>
|
|
147
|
+
popupRef.current?.querySelector<HTMLElement>(
|
|
148
|
+
'[data-date][tabindex="0"]',
|
|
149
|
+
) ?? true
|
|
150
|
+
}
|
|
151
|
+
className={cn(menuPanel, "flex flex-col gap-2 p-2")}
|
|
152
|
+
>
|
|
153
|
+
{children}
|
|
154
|
+
</BasePopover.Popup>
|
|
155
|
+
</BasePopover.Positioner>
|
|
156
|
+
</BasePopover.Portal>
|
|
157
|
+
</BasePopover.Root>
|
|
158
|
+
);
|
|
159
|
+
}
|