@endfield-ui/react 0.0.0-stage → 0.1.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 +768 -2
- package/dist/index.d.ts +3243 -0
- package/dist/index.js +8182 -0
- package/dist/styles.css +2 -0
- package/package.json +83 -3
- package/src/components/README.md +172 -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 +89 -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 +292 -0
- package/src/components/carousel/index.ts +7 -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/icon-button/IconButton.tsx +95 -0
- package/src/components/icon-button/index.ts +6 -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 +283 -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/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 +105 -0
- package/src/components/popover/index.ts +8 -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-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 +559 -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 +501 -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 +262 -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 +82 -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,292 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
isValidElement,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
type ComponentProps,
|
|
10
|
+
type KeyboardEvent,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { useControllableState } from "../../hooks/useControllableState";
|
|
14
|
+
import { ChevronLeft } from "../../icons/ChevronLeft";
|
|
15
|
+
import { ChevronRight } from "../../icons/ChevronRight";
|
|
16
|
+
import { cn } from "../../lib/cn";
|
|
17
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
18
|
+
import { DashIndicator } from "../dash-indicator/DashIndicator";
|
|
19
|
+
import { IconButton } from "../icon-button/IconButton";
|
|
20
|
+
|
|
21
|
+
export type CarouselRatio = "16/9" | "4/3" | "1/1" | "3/4" | "21/9";
|
|
22
|
+
|
|
23
|
+
const ratioClass: Record<CarouselRatio, string> = {
|
|
24
|
+
"16/9": "aspect-video",
|
|
25
|
+
"4/3": "aspect-4/3",
|
|
26
|
+
"1/1": "aspect-square",
|
|
27
|
+
"3/4": "aspect-3/4",
|
|
28
|
+
"21/9": "aspect-21/9",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const SlideContext = createContext<{
|
|
32
|
+
position: number;
|
|
33
|
+
count: number;
|
|
34
|
+
current: boolean;
|
|
35
|
+
} | null>(null);
|
|
36
|
+
|
|
37
|
+
export type CarouselProps = Omit<
|
|
38
|
+
ComponentProps<"section">,
|
|
39
|
+
"aria-label" | "onChange"
|
|
40
|
+
> & {
|
|
41
|
+
/** 这组媒体是什么,比如"玩法介绍" */
|
|
42
|
+
"aria-label": string;
|
|
43
|
+
/** 现在是第几张,从 0 起 */
|
|
44
|
+
index?: number;
|
|
45
|
+
defaultIndex?: number;
|
|
46
|
+
onIndexChange?: (index: number) => void;
|
|
47
|
+
/** 到头之后绕回另一头。默认到头时翻页钮禁用 */
|
|
48
|
+
loop?: boolean;
|
|
49
|
+
/** 媒体的宽高比,默认 `16/9` */
|
|
50
|
+
ratio?: CarouselRatio;
|
|
51
|
+
/** 计数旁边再加一排进度短横 */
|
|
52
|
+
indicator?: boolean;
|
|
53
|
+
previousLabel?: string;
|
|
54
|
+
nextLabel?: string;
|
|
55
|
+
/** 直接放 `CarouselSlide` */
|
|
56
|
+
children: ReactNode;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type SlideLike = { title?: ReactNode; description?: ReactNode };
|
|
60
|
+
|
|
61
|
+
/** 滚动停下之后多久算"停稳了"。没有 scrollend 事件的浏览器靠它 */
|
|
62
|
+
const SETTLE_MS = 120;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 媒体轮播:一次看一张的大幅媒体,前后翻。不自动播放。
|
|
66
|
+
* 轨道是原生的横向滚动加滚动吸附,触屏滑动是浏览器自带的。
|
|
67
|
+
*/
|
|
68
|
+
export function Carousel({
|
|
69
|
+
index: indexProp,
|
|
70
|
+
defaultIndex = 0,
|
|
71
|
+
onIndexChange,
|
|
72
|
+
loop = false,
|
|
73
|
+
ratio = "16/9",
|
|
74
|
+
indicator = false,
|
|
75
|
+
previousLabel = "上一张",
|
|
76
|
+
nextLabel = "下一张",
|
|
77
|
+
className,
|
|
78
|
+
children,
|
|
79
|
+
...props
|
|
80
|
+
}: CarouselProps) {
|
|
81
|
+
// 标题和说明从子元素上读,所以子元素必须直接是 CarouselSlide
|
|
82
|
+
const slides = Children.toArray(children).filter((child) =>
|
|
83
|
+
isValidElement<SlideLike>(child),
|
|
84
|
+
);
|
|
85
|
+
const count = slides.length;
|
|
86
|
+
const last = Math.max(0, count - 1);
|
|
87
|
+
|
|
88
|
+
const [rawIndex, setIndex] = useControllableState({
|
|
89
|
+
value: indexProp,
|
|
90
|
+
defaultValue: defaultIndex,
|
|
91
|
+
onChange: onIndexChange,
|
|
92
|
+
});
|
|
93
|
+
const index = Math.min(Math.max(rawIndex, 0), last);
|
|
94
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
95
|
+
const settleTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
96
|
+
// 给不跟着渲染重建的回调(尺寸变化)读最新的下标
|
|
97
|
+
const indexRef = useRef(index);
|
|
98
|
+
indexRef.current = index;
|
|
99
|
+
|
|
100
|
+
const go = (next: number) => {
|
|
101
|
+
if (count === 0) return;
|
|
102
|
+
const target = loop
|
|
103
|
+
? (next + count) % count
|
|
104
|
+
: Math.min(Math.max(next, 0), last);
|
|
105
|
+
setIndex(target);
|
|
106
|
+
// 到头了,刚按的那个翻页钮马上要禁用:把焦点交给媒体,键盘还能接着用方向键翻回去
|
|
107
|
+
if (
|
|
108
|
+
!loop &&
|
|
109
|
+
(target === 0 || target === last) &&
|
|
110
|
+
document.activeElement instanceof HTMLButtonElement &&
|
|
111
|
+
trackRef.current?.parentElement?.contains(document.activeElement)
|
|
112
|
+
) {
|
|
113
|
+
trackRef.current.focus({ preventScroll: true });
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// 下标变了:滚过去。已经在那儿(用户自己滑过去的)就不动
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const track = trackRef.current;
|
|
120
|
+
if (!track || track.clientWidth === 0) return;
|
|
121
|
+
if (Math.round(track.scrollLeft / track.clientWidth) === index) return;
|
|
122
|
+
track.scrollTo?.({ left: index * track.clientWidth, behavior: "smooth" });
|
|
123
|
+
}, [index]);
|
|
124
|
+
|
|
125
|
+
// 宽度变了:轨道的滚动位置是像素,会错开,重新对到当前这一张(不要动画)
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const track = trackRef.current;
|
|
128
|
+
if (!track || typeof ResizeObserver === "undefined") return;
|
|
129
|
+
const observer = new ResizeObserver(() => {
|
|
130
|
+
track.scrollTo?.({
|
|
131
|
+
left: indexRef.current * track.clientWidth,
|
|
132
|
+
behavior: "instant",
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
observer.observe(track);
|
|
136
|
+
return () => observer.disconnect();
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
useEffect(() => () => clearTimeout(settleTimer.current), []);
|
|
140
|
+
|
|
141
|
+
// 用户自己滑:等滚动停稳,由停下的位置定下标。滚动途中不改——
|
|
142
|
+
// 翻页钮触发的滚动会路过中间那几张
|
|
143
|
+
const onScroll = useCallback(() => {
|
|
144
|
+
clearTimeout(settleTimer.current);
|
|
145
|
+
settleTimer.current = setTimeout(() => {
|
|
146
|
+
const track = trackRef.current;
|
|
147
|
+
if (!track || track.clientWidth === 0) return;
|
|
148
|
+
const stopped = Math.round(track.scrollLeft / track.clientWidth);
|
|
149
|
+
setIndex(Math.min(Math.max(stopped, 0), last));
|
|
150
|
+
}, SETTLE_MS);
|
|
151
|
+
}, [setIndex, last]);
|
|
152
|
+
|
|
153
|
+
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
154
|
+
const next = {
|
|
155
|
+
ArrowLeft: index - 1,
|
|
156
|
+
ArrowRight: index + 1,
|
|
157
|
+
Home: 0,
|
|
158
|
+
End: last,
|
|
159
|
+
}[event.key];
|
|
160
|
+
if (next === undefined) return;
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
go(next);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const current = slides[index] as { props: SlideLike } | undefined;
|
|
166
|
+
const place = `第 ${index + 1} 张,共 ${count} 张`;
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<section
|
|
170
|
+
{...props}
|
|
171
|
+
role="group"
|
|
172
|
+
aria-roledescription="轮播"
|
|
173
|
+
data-ratio={ratio}
|
|
174
|
+
className={cn("flex flex-col gap-3 text-ink", className)}
|
|
175
|
+
>
|
|
176
|
+
<div className="relative isolate">
|
|
177
|
+
<div
|
|
178
|
+
ref={trackRef}
|
|
179
|
+
// 能聚焦:键盘要能在媒体上用方向键翻页
|
|
180
|
+
tabIndex={0}
|
|
181
|
+
onScroll={onScroll}
|
|
182
|
+
// 只管焦点就在轨道上的时候:幻灯片里的控件(视频的进度条)自己要用方向键
|
|
183
|
+
onKeyDown={(event) => {
|
|
184
|
+
if (event.target === event.currentTarget) onKeyDown(event);
|
|
185
|
+
}}
|
|
186
|
+
className={cn(
|
|
187
|
+
"flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain bg-surface-muted [scrollbar-width:none]",
|
|
188
|
+
// 环画在外面:画在里面会被幻灯片盖住(它们是定位元素,画在轨道的轮廓之上)
|
|
189
|
+
focusRing,
|
|
190
|
+
ratioClass[ratio],
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
{slides.map((slide, position) => (
|
|
194
|
+
<SlideContext
|
|
195
|
+
key={position}
|
|
196
|
+
value={{ position, count, current: position === index }}
|
|
197
|
+
>
|
|
198
|
+
{slide}
|
|
199
|
+
</SlideContext>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
{/* 压在媒体左下角的一对圆钮。它们在轨道外面:轨道滚的时候不跟着走 */}
|
|
203
|
+
<div
|
|
204
|
+
onKeyDown={onKeyDown}
|
|
205
|
+
className="absolute bottom-3 left-3 z-1 flex gap-2"
|
|
206
|
+
>
|
|
207
|
+
<IconButton
|
|
208
|
+
variant="floating"
|
|
209
|
+
aria-label={previousLabel}
|
|
210
|
+
disabled={!loop && index <= 0}
|
|
211
|
+
onClick={() => go(index - 1)}
|
|
212
|
+
>
|
|
213
|
+
<ChevronLeft />
|
|
214
|
+
</IconButton>
|
|
215
|
+
<IconButton
|
|
216
|
+
variant="floating"
|
|
217
|
+
aria-label={nextLabel}
|
|
218
|
+
disabled={!loop && index >= last}
|
|
219
|
+
onClick={() => go(index + 1)}
|
|
220
|
+
>
|
|
221
|
+
<ChevronRight />
|
|
222
|
+
</IconButton>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div className="flex flex-col gap-1">
|
|
227
|
+
{/* 看得见的计数和短横只给眼睛:同样的话下面的说明区会播报 */}
|
|
228
|
+
<div aria-hidden="true" className="flex items-center gap-3">
|
|
229
|
+
<span className="font-tech text-xs text-ink-secondary tabular-nums">
|
|
230
|
+
{`${index + 1} / ${count}`}
|
|
231
|
+
</span>
|
|
232
|
+
{indicator && <DashIndicator count={count} index={index} />}
|
|
233
|
+
</div>
|
|
234
|
+
<div aria-live="polite" className="flex flex-col gap-1">
|
|
235
|
+
<span className="sr-only">{place}</span>
|
|
236
|
+
{current?.props.title && (
|
|
237
|
+
<p className="text-lg font-bold wrap-anywhere">
|
|
238
|
+
{current.props.title}
|
|
239
|
+
</p>
|
|
240
|
+
)}
|
|
241
|
+
{current?.props.description && (
|
|
242
|
+
<p className="text-sm text-ink-secondary">
|
|
243
|
+
{current.props.description}
|
|
244
|
+
</p>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</section>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type CarouselSlideProps = Omit<ComponentProps<"div">, "title"> & {
|
|
253
|
+
/** 这一张的标题,显示在媒体下方 */
|
|
254
|
+
title?: ReactNode;
|
|
255
|
+
/** 标题下面的一段正文 */
|
|
256
|
+
description?: ReactNode;
|
|
257
|
+
/** 媒体:一张图或一段视频,铺满并裁切 */
|
|
258
|
+
children: ReactNode;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/** 轮播里的一张。直接放在 `Carousel` 里。 */
|
|
262
|
+
export function CarouselSlide({
|
|
263
|
+
// 标题和说明由 Carousel 读走,画在媒体下面
|
|
264
|
+
title: _title,
|
|
265
|
+
description: _description,
|
|
266
|
+
className,
|
|
267
|
+
children,
|
|
268
|
+
...props
|
|
269
|
+
}: CarouselSlideProps) {
|
|
270
|
+
const slide = useContext(SlideContext);
|
|
271
|
+
if (!slide) {
|
|
272
|
+
throw new Error("CarouselSlide 要直接放在 Carousel 里面");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<div
|
|
277
|
+
{...props}
|
|
278
|
+
role="group"
|
|
279
|
+
aria-roledescription="幻灯片"
|
|
280
|
+
aria-label={`第 ${slide.position + 1} 张,共 ${slide.count} 张`}
|
|
281
|
+
// 不在眼前的:里面的链接、视频控件 Tab 走不进去,读屏也读不到
|
|
282
|
+
inert={!slide.current}
|
|
283
|
+
data-current={slide.current ? "" : undefined}
|
|
284
|
+
className={cn(
|
|
285
|
+
"relative size-full shrink-0 snap-start overflow-clip *:size-full *:object-cover",
|
|
286
|
+
className,
|
|
287
|
+
)}
|
|
288
|
+
>
|
|
289
|
+
{children}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
type ChangeEvent,
|
|
5
|
+
type ComponentProps,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { Check } from "../../icons/Check";
|
|
9
|
+
import { Minus } from "../../icons/Minus";
|
|
10
|
+
import { cn } from "../../lib/cn";
|
|
11
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
12
|
+
import { mergeRefs } from "../../lib/merge-refs";
|
|
13
|
+
import {
|
|
14
|
+
choiceBox,
|
|
15
|
+
choiceControl,
|
|
16
|
+
choiceFrame,
|
|
17
|
+
choiceLabel,
|
|
18
|
+
choiceMark,
|
|
19
|
+
} from "./choice-style";
|
|
20
|
+
|
|
21
|
+
export type CheckboxProps = Omit<ComponentProps<"input">, "type" | "size"> & {
|
|
22
|
+
/** 半选:一组子项只选了一部分。显示为短横,读屏读作"部分选中" */
|
|
23
|
+
indeterminate?: boolean;
|
|
24
|
+
/** 错误态:描边变红 */
|
|
25
|
+
invalid?: boolean;
|
|
26
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
27
|
+
/** 右侧的文字标签。不传时必须自己给 `aria-label` */
|
|
28
|
+
children?: ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 复选框。原生 `<input type="checkbox">`,受控用 `checked`、非受控用 `defaultChecked`。
|
|
33
|
+
* `className` 给整行,其余属性与 `ref` 给 `<input>`。
|
|
34
|
+
*/
|
|
35
|
+
export function Checkbox({
|
|
36
|
+
indeterminate = false,
|
|
37
|
+
invalid = false,
|
|
38
|
+
onCheckedChange,
|
|
39
|
+
onChange,
|
|
40
|
+
className,
|
|
41
|
+
children,
|
|
42
|
+
ref,
|
|
43
|
+
...props
|
|
44
|
+
}: CheckboxProps) {
|
|
45
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
46
|
+
|
|
47
|
+
// indeterminate 只能通过 DOM 属性设置,没有对应的 HTML 特性
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
|
|
50
|
+
}, [indeterminate]);
|
|
51
|
+
|
|
52
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
53
|
+
onChange?.(event);
|
|
54
|
+
onCheckedChange?.(event.target.checked);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<label className={cn(choiceLabel, className)}>
|
|
59
|
+
<span className={choiceControl}>
|
|
60
|
+
<span className={choiceFrame}>
|
|
61
|
+
<input
|
|
62
|
+
{...props}
|
|
63
|
+
ref={mergeRefs(inputRef, ref)}
|
|
64
|
+
type="checkbox"
|
|
65
|
+
aria-invalid={invalid || undefined}
|
|
66
|
+
onChange={handleChange}
|
|
67
|
+
className={cn(
|
|
68
|
+
choiceBox,
|
|
69
|
+
// 半选只属于复选框:一组单选都没选时也会命中 :indeterminate,
|
|
70
|
+
// 所以这两个类不能放进与单选共用的 choiceBox
|
|
71
|
+
"indeterminate:border-surface-inverse indeterminate:bg-surface-inverse",
|
|
72
|
+
focusRing,
|
|
73
|
+
)}
|
|
74
|
+
/>
|
|
75
|
+
<Check
|
|
76
|
+
size={14}
|
|
77
|
+
strokeWidth={3}
|
|
78
|
+
className={cn(
|
|
79
|
+
choiceMark,
|
|
80
|
+
// 菱形方案下"已选"就是实心菱形,不画对勾
|
|
81
|
+
"peer-checked:opacity-100 peer-indeterminate:opacity-0 choice-diamond:hidden",
|
|
82
|
+
)}
|
|
83
|
+
/>
|
|
84
|
+
<Minus
|
|
85
|
+
size={14}
|
|
86
|
+
strokeWidth={3}
|
|
87
|
+
className={cn(choiceMark, "peer-indeterminate:opacity-100")}
|
|
88
|
+
/>
|
|
89
|
+
</span>
|
|
90
|
+
</span>
|
|
91
|
+
{children !== undefined && <span className="min-w-0">{children}</span>}
|
|
92
|
+
</label>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 复选、单选、开关共用的一行:真正的 <input> 画成控件本身,文字标签在右,
|
|
3
|
+
* 点击标签等同点击控件,整行不低于 40px。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const choiceLabel =
|
|
7
|
+
"group inline-flex min-h-10 items-start gap-2 py-2 text-base text-ink has-disabled:cursor-not-allowed has-disabled:text-ink-disabled";
|
|
8
|
+
|
|
9
|
+
/** 控件的定位盒:高度等于一行文字,标签换行时控件仍然对齐第一行 */
|
|
10
|
+
export const choiceControl = "flex h-[1lh] shrink-0 items-center";
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* 复选框与单选框的方格 / 圆:
|
|
14
|
+
* 未选是凹陷底 + 2px 描边;已选是反转块,上面压 accent-ink-inverse 的记号——
|
|
15
|
+
* 亮色下是墨底黄记号,暗色下反转块是近白,记号换成深色档。
|
|
16
|
+
*
|
|
17
|
+
* 祖先上有 data-choice="diamond" 时换成菱形(方案 B):方格缩到 14px 并转 45°,
|
|
18
|
+
* 对角线正好还是 20px;未选是空心菱形,已选是实心的,不再画对勾和圆点——
|
|
19
|
+
* 所以禁用的"已选"也要保持实心,否则和"未选"分不出来。
|
|
20
|
+
*/
|
|
21
|
+
export const choiceBox =
|
|
22
|
+
"peer size-5 shrink-0 appearance-none border-2 choice-diamond:size-3.5 choice-diamond:rotate-45 choice-diamond:checked:disabled:bg-ink-disabled border-line-strong bg-surface-sunken transition-colors duration-(--duration-fast) ease-standard group-hover:border-ink-secondary checked:border-surface-inverse checked:bg-surface-inverse aria-invalid:border-danger disabled:cursor-not-allowed disabled:border-ink-disabled disabled:bg-surface-sunken";
|
|
23
|
+
|
|
24
|
+
/** 方格 / 圆的定位盒:菱形比它小一圈,所以居中 */
|
|
25
|
+
export const choiceFrame =
|
|
26
|
+
"relative inline-flex size-5 items-center justify-center";
|
|
27
|
+
|
|
28
|
+
/** 压在方格 / 圆正中的记号,默认透明,由 peer 的状态点亮 */
|
|
29
|
+
export const choiceMark =
|
|
30
|
+
"pointer-events-none absolute inset-0 m-auto text-accent-ink-inverse opacity-0 peer-disabled:text-ink-disabled";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Checkbox, type CheckboxProps } from "./Checkbox";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ComponentProps, MouseEvent } from "react";
|
|
2
|
+
import { useControllableState } from "../../hooks/useControllableState";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
5
|
+
import {
|
|
6
|
+
capsuleBase,
|
|
7
|
+
capsuleSize,
|
|
8
|
+
capsuleState,
|
|
9
|
+
type CapsuleSize,
|
|
10
|
+
} from "./capsule-style";
|
|
11
|
+
|
|
12
|
+
export type FilterChipProps = Omit<ComponentProps<"button">, "aria-pressed"> & {
|
|
13
|
+
/** 受控的选中状态 */
|
|
14
|
+
selected?: boolean;
|
|
15
|
+
/** 非受控时的初始状态 */
|
|
16
|
+
defaultSelected?: boolean;
|
|
17
|
+
onSelectedChange?: (selected: boolean) => void;
|
|
18
|
+
size?: CapsuleSize;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 筛选胶囊:一个可以独立开关的条件,几个并排就是多选筛选。
|
|
23
|
+
* 几项里只能选一项时用 `Tabs` 的 `capsule` 变体或单选。
|
|
24
|
+
*/
|
|
25
|
+
export function FilterChip({
|
|
26
|
+
selected,
|
|
27
|
+
defaultSelected = false,
|
|
28
|
+
onSelectedChange,
|
|
29
|
+
size = "md",
|
|
30
|
+
disabled = false,
|
|
31
|
+
type = "button",
|
|
32
|
+
className,
|
|
33
|
+
onClick,
|
|
34
|
+
...props
|
|
35
|
+
}: FilterChipProps) {
|
|
36
|
+
const [on, setOn] = useControllableState({
|
|
37
|
+
value: selected,
|
|
38
|
+
defaultValue: defaultSelected,
|
|
39
|
+
onChange: onSelectedChange,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
{...props}
|
|
45
|
+
type={type}
|
|
46
|
+
disabled={disabled}
|
|
47
|
+
aria-pressed={on}
|
|
48
|
+
data-selected={on ? "" : undefined}
|
|
49
|
+
onClick={(event: MouseEvent<HTMLButtonElement>) => {
|
|
50
|
+
onClick?.(event);
|
|
51
|
+
if (!event.defaultPrevented) setOn(!on);
|
|
52
|
+
}}
|
|
53
|
+
className={cn(
|
|
54
|
+
capsuleBase,
|
|
55
|
+
focusRing,
|
|
56
|
+
capsuleSize[size],
|
|
57
|
+
disabled
|
|
58
|
+
? on
|
|
59
|
+
? capsuleState.selectedDisabled
|
|
60
|
+
: capsuleState.disabled
|
|
61
|
+
: on
|
|
62
|
+
? capsuleState.selected
|
|
63
|
+
: capsuleState.rest,
|
|
64
|
+
className,
|
|
65
|
+
)}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type ResourceChipSize = "sm" | "md";
|
|
5
|
+
|
|
6
|
+
export type ResourceChipProps = Omit<ComponentProps<"span">, "children"> & {
|
|
7
|
+
/** 资源的图标,放进左端的圆形底托。本库不带资源图标,由使用方提供 */
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
/** 数量,等宽数字 */
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/** 给读屏的完整说明,如"燃料 1,280"。提供后数字本身对辅助技术隐藏 */
|
|
12
|
+
label?: string;
|
|
13
|
+
size?: ResourceChipSize;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const sizeClass: Record<ResourceChipSize, string> = {
|
|
17
|
+
sm: "h-6 gap-1 pr-2.5 text-xs",
|
|
18
|
+
md: "h-7 gap-1.5 pr-3 text-sm",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const iconPadding: Record<ResourceChipSize, string> = {
|
|
22
|
+
sm: "pl-0.5",
|
|
23
|
+
md: "pl-1",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const plainPadding: Record<ResourceChipSize, string> = {
|
|
27
|
+
sm: "pl-2.5",
|
|
28
|
+
md: "pl-3",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** 资源胶囊:货币、材料的当前数量——会变的状态,所以是胶囊。 */
|
|
32
|
+
export function ResourceChip({
|
|
33
|
+
icon,
|
|
34
|
+
label,
|
|
35
|
+
size = "md",
|
|
36
|
+
className,
|
|
37
|
+
children,
|
|
38
|
+
...props
|
|
39
|
+
}: ResourceChipProps) {
|
|
40
|
+
return (
|
|
41
|
+
<span
|
|
42
|
+
{...props}
|
|
43
|
+
className={cn(
|
|
44
|
+
// 有意不随主题翻转:control 系列在两个主题下取值相同
|
|
45
|
+
"inline-flex shrink-0 items-center rounded-full bg-control font-tech leading-none font-bold whitespace-nowrap text-on-control tabular-nums",
|
|
46
|
+
sizeClass[size],
|
|
47
|
+
icon ? iconPadding[size] : plainPadding[size],
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{icon && (
|
|
52
|
+
<span
|
|
53
|
+
aria-hidden="true"
|
|
54
|
+
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-on-control/15 [&_svg]:size-3"
|
|
55
|
+
>
|
|
56
|
+
{icon}
|
|
57
|
+
</span>
|
|
58
|
+
)}
|
|
59
|
+
<span aria-hidden={label ? "true" : undefined}>{children}</span>
|
|
60
|
+
{label && <span className="sr-only">{label}</span>}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 可选中的胶囊:细边,选中时反转为墨底。
|
|
3
|
+
* 筛选胶囊与页签的 `capsule` 变体是同一个外观,类名放在一处免得两边走样。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type CapsuleSize = "sm" | "md";
|
|
7
|
+
|
|
8
|
+
export const capsuleBase =
|
|
9
|
+
"inline-flex shrink-0 items-center rounded-full border leading-none font-medium whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard";
|
|
10
|
+
|
|
11
|
+
export const capsuleSize: Record<CapsuleSize, string> = {
|
|
12
|
+
sm: "h-7 px-3 text-xs",
|
|
13
|
+
md: "h-8 px-4 text-sm",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const capsuleState = {
|
|
17
|
+
selected: "border-transparent bg-surface-inverse text-ink-inverse",
|
|
18
|
+
rest: "border-line-strong text-ink hover:bg-ink/5",
|
|
19
|
+
disabled: "cursor-not-allowed border-line text-ink-disabled",
|
|
20
|
+
selectedDisabled:
|
|
21
|
+
"cursor-not-allowed border-transparent bg-disabled text-on-disabled",
|
|
22
|
+
} as const;
|