@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,288 @@
|
|
|
1
|
+
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
useContext,
|
|
5
|
+
useRef,
|
|
6
|
+
type ComponentProps,
|
|
7
|
+
type MouseEvent,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { useControllableState } from "../../hooks/useControllableState";
|
|
12
|
+
import { usePortalScope } from "../../hooks/usePortalScope";
|
|
13
|
+
import { ArrowRight } from "../../icons/ArrowRight";
|
|
14
|
+
import { Close } from "../../icons/Close";
|
|
15
|
+
import { cn } from "../../lib/cn";
|
|
16
|
+
import { focusRingInset } from "../../lib/focus-ring";
|
|
17
|
+
import { firstFocusableIn } from "../dialog/initial-focus";
|
|
18
|
+
import { GhostText } from "../ghost-text/GhostText";
|
|
19
|
+
import { IconButton } from "../icon-button/IconButton";
|
|
20
|
+
import { LinkElement, type LinkRender } from "../link-element/LinkElement";
|
|
21
|
+
import { NavActionContext } from "../nav-action/NavAction";
|
|
22
|
+
|
|
23
|
+
/** 导航项点了之后要把菜单关上 */
|
|
24
|
+
const NavMenuContext = createContext<() => void>(() => {});
|
|
25
|
+
|
|
26
|
+
export type NavMenuProps = {
|
|
27
|
+
/** 菜单钮,通常是顶栏最右边的 `<IconButton>`。不传就用 `open` / `onOpenChange` 自己控制 */
|
|
28
|
+
trigger?: ReactElement<Record<string, unknown>>;
|
|
29
|
+
open?: boolean;
|
|
30
|
+
defaultOpen?: boolean;
|
|
31
|
+
onOpenChange?: (open: boolean) => void;
|
|
32
|
+
/** 菜单的名称,读屏打开时读它。没有 `brand` 时也显示在最上面一行 */
|
|
33
|
+
title: ReactNode;
|
|
34
|
+
/** 最上面一行左边的标志,通常和顶栏的是同一个 */
|
|
35
|
+
brand?: ReactNode;
|
|
36
|
+
/** 导航项下面的一排工具按钮 */
|
|
37
|
+
tools?: ReactNode;
|
|
38
|
+
/** 底部通宽的按钮:放一到两个 `NavAction` */
|
|
39
|
+
footer?: ReactNode;
|
|
40
|
+
/** 背景底部的一行镂空巨字,纯装饰。写一个短词 */
|
|
41
|
+
ghost?: string;
|
|
42
|
+
/** 里面那组栏目的名称 */
|
|
43
|
+
navLabel?: string;
|
|
44
|
+
/** 关闭钮的可访问名称 */
|
|
45
|
+
closeLabel?: string;
|
|
46
|
+
/** 给整块面板 */
|
|
47
|
+
className?: string;
|
|
48
|
+
/** `NavMenuItem` */
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 全屏菜单:窄屏上顶栏的菜单钮打开的那一整屏栏目。
|
|
54
|
+
* 打开时焦点移入并被限制在内、背景不可滚动;`Esc` 关闭,焦点回到菜单钮。
|
|
55
|
+
*/
|
|
56
|
+
export function NavMenu({
|
|
57
|
+
trigger,
|
|
58
|
+
open,
|
|
59
|
+
defaultOpen = false,
|
|
60
|
+
onOpenChange,
|
|
61
|
+
title,
|
|
62
|
+
brand,
|
|
63
|
+
tools,
|
|
64
|
+
footer,
|
|
65
|
+
ghost,
|
|
66
|
+
navLabel = "主导航",
|
|
67
|
+
closeLabel = "关闭菜单",
|
|
68
|
+
className,
|
|
69
|
+
children,
|
|
70
|
+
}: NavMenuProps) {
|
|
71
|
+
const [isOpen, setOpen] = useControllableState({
|
|
72
|
+
value: open,
|
|
73
|
+
defaultValue: defaultOpen,
|
|
74
|
+
onChange: onOpenChange,
|
|
75
|
+
});
|
|
76
|
+
const { anchorRef, portalRef } = usePortalScope();
|
|
77
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<BaseDialog.Root open={isOpen} onOpenChange={(next) => setOpen(next)}>
|
|
81
|
+
{trigger && <BaseDialog.Trigger ref={anchorRef} render={trigger} />}
|
|
82
|
+
<BaseDialog.Portal ref={portalRef}>
|
|
83
|
+
<BaseDialog.Popup
|
|
84
|
+
// 焦点先落在第一个栏目上,而不是关闭钮
|
|
85
|
+
initialFocus={firstFocusableIn(bodyRef)}
|
|
86
|
+
className={cn(
|
|
87
|
+
"fixed inset-0 z-(--z-overlay) flex flex-col overflow-hidden bg-surface text-ink outline-none",
|
|
88
|
+
"transition-opacity duration-(--duration-fast) ease-standard",
|
|
89
|
+
"data-ending-style:opacity-0 data-starting-style:opacity-0",
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
{/* 和顶栏等高:关闭钮正好落在菜单钮的位置上 */}
|
|
94
|
+
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-line bg-surface px-4">
|
|
95
|
+
<div className="flex min-w-0 flex-1 items-center">
|
|
96
|
+
{brand}
|
|
97
|
+
<BaseDialog.Title
|
|
98
|
+
className={brand ? "sr-only" : "truncate text-base font-medium"}
|
|
99
|
+
>
|
|
100
|
+
{title}
|
|
101
|
+
</BaseDialog.Title>
|
|
102
|
+
</div>
|
|
103
|
+
<BaseDialog.Close
|
|
104
|
+
render={
|
|
105
|
+
<IconButton aria-label={closeLabel}>
|
|
106
|
+
<Close />
|
|
107
|
+
</IconButton>
|
|
108
|
+
}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div
|
|
113
|
+
ref={bodyRef}
|
|
114
|
+
className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain"
|
|
115
|
+
>
|
|
116
|
+
<NavMenuContext value={() => setOpen(false)}>
|
|
117
|
+
<nav aria-label={navLabel}>
|
|
118
|
+
<ul className="flex flex-col gap-1 p-4">{children}</ul>
|
|
119
|
+
</nav>
|
|
120
|
+
</NavMenuContext>
|
|
121
|
+
|
|
122
|
+
{tools && (
|
|
123
|
+
<div className="mx-4 flex flex-wrap gap-1 bg-surface-muted p-1">
|
|
124
|
+
{tools}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{/* 栏目和底部按钮之间的空白。巨字贴着它的下沿,下半截裁掉 */}
|
|
129
|
+
<div
|
|
130
|
+
aria-hidden="true"
|
|
131
|
+
className={cn(
|
|
132
|
+
"relative flex-1 overflow-hidden",
|
|
133
|
+
ghost && "min-h-20",
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{ghost && (
|
|
137
|
+
<GhostText
|
|
138
|
+
variant="outline"
|
|
139
|
+
className="absolute bottom-0 left-4 translate-y-1/4"
|
|
140
|
+
>
|
|
141
|
+
{ghost}
|
|
142
|
+
</GhostText>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{footer && (
|
|
147
|
+
<div className="flex shrink-0 flex-col gap-2 p-4">
|
|
148
|
+
<NavActionContext value="block">{footer}</NavActionContext>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
</BaseDialog.Popup>
|
|
153
|
+
</BaseDialog.Portal>
|
|
154
|
+
</BaseDialog.Root>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type ItemOwnProps = {
|
|
159
|
+
/** 图标,后面跟一条竖向的分隔线 */
|
|
160
|
+
icon?: ReactNode;
|
|
161
|
+
/** 当前所在的栏目:整条变信号黄,输出 `aria-current="page"` */
|
|
162
|
+
current?: boolean;
|
|
163
|
+
disabled?: boolean;
|
|
164
|
+
/** 点了之后是否关掉菜单,默认关 */
|
|
165
|
+
closeOnClick?: boolean;
|
|
166
|
+
/** 传了就渲染成链接 */
|
|
167
|
+
href?: string;
|
|
168
|
+
target?: string;
|
|
169
|
+
rel?: string;
|
|
170
|
+
/** 用这个元素代替 `<a>`(路由库的链接组件)。传了就按链接处理 */
|
|
171
|
+
render?: LinkRender;
|
|
172
|
+
/** 没有 `href` 和 `render` 时渲染成按钮 */
|
|
173
|
+
onClick?: (event: MouseEvent<HTMLElement>) => void;
|
|
174
|
+
children: ReactNode;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export type NavMenuItemProps = ItemOwnProps &
|
|
178
|
+
Omit<ComponentProps<"li">, keyof ItemOwnProps>;
|
|
179
|
+
|
|
180
|
+
/** 全屏菜单里的一个栏目。 */
|
|
181
|
+
export function NavMenuItem({
|
|
182
|
+
icon,
|
|
183
|
+
current = false,
|
|
184
|
+
disabled = false,
|
|
185
|
+
closeOnClick = true,
|
|
186
|
+
href,
|
|
187
|
+
target,
|
|
188
|
+
rel,
|
|
189
|
+
render,
|
|
190
|
+
onClick,
|
|
191
|
+
children,
|
|
192
|
+
...props
|
|
193
|
+
}: NavMenuItemProps) {
|
|
194
|
+
const close = useContext(NavMenuContext);
|
|
195
|
+
const isLink = href !== undefined || render !== undefined;
|
|
196
|
+
|
|
197
|
+
const handleClick = (event: MouseEvent<HTMLElement>) => {
|
|
198
|
+
if (disabled) {
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
onClick?.(event);
|
|
203
|
+
if (closeOnClick) close();
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const classes = cn(
|
|
207
|
+
"relative flex h-14 w-full items-center gap-3 px-4 text-left text-base font-medium",
|
|
208
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
209
|
+
disabled
|
|
210
|
+
? "cursor-not-allowed bg-surface-sunken text-ink-disabled"
|
|
211
|
+
: current
|
|
212
|
+
? [
|
|
213
|
+
// 黄和旁边的浅灰明度很接近:另加粗体和左缘一条墨色竖条,不只靠颜色
|
|
214
|
+
"bg-action font-bold text-on-action",
|
|
215
|
+
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-on-action before:content-['']",
|
|
216
|
+
// 普通的焦点色在暗色主题下也是黄的,压在黄底上看不见
|
|
217
|
+
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-on-action",
|
|
218
|
+
]
|
|
219
|
+
: [
|
|
220
|
+
"bg-surface-sunken text-ink hover:bg-surface-muted active:bg-line",
|
|
221
|
+
focusRingInset,
|
|
222
|
+
],
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const mark = disabled
|
|
226
|
+
? "text-ink-disabled"
|
|
227
|
+
: current
|
|
228
|
+
? "text-on-action"
|
|
229
|
+
: "text-ink-secondary";
|
|
230
|
+
|
|
231
|
+
const content = (
|
|
232
|
+
<>
|
|
233
|
+
{icon && (
|
|
234
|
+
<>
|
|
235
|
+
<span
|
|
236
|
+
aria-hidden="true"
|
|
237
|
+
className={cn(
|
|
238
|
+
"flex size-6 shrink-0 items-center justify-center [&_svg]:size-5",
|
|
239
|
+
mark,
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{icon}
|
|
243
|
+
</span>
|
|
244
|
+
<span
|
|
245
|
+
aria-hidden="true"
|
|
246
|
+
className={cn(
|
|
247
|
+
"h-5 w-px shrink-0",
|
|
248
|
+
current && !disabled ? "bg-on-action" : "bg-line-strong",
|
|
249
|
+
)}
|
|
250
|
+
/>
|
|
251
|
+
</>
|
|
252
|
+
)}
|
|
253
|
+
<span className="min-w-0 flex-1 truncate">{children}</span>
|
|
254
|
+
<ArrowRight size={16} className={cn("shrink-0", mark)} />
|
|
255
|
+
</>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<li {...props}>
|
|
260
|
+
{isLink ? (
|
|
261
|
+
<LinkElement
|
|
262
|
+
render={render}
|
|
263
|
+
// 禁用的链接去掉 href,保留 link 角色,让读屏仍能读出"不可用"
|
|
264
|
+
href={disabled ? undefined : href}
|
|
265
|
+
role={disabled ? "link" : undefined}
|
|
266
|
+
aria-disabled={disabled || undefined}
|
|
267
|
+
target={target}
|
|
268
|
+
rel={rel}
|
|
269
|
+
aria-current={current ? "page" : undefined}
|
|
270
|
+
onClick={handleClick}
|
|
271
|
+
className={classes}
|
|
272
|
+
>
|
|
273
|
+
{content}
|
|
274
|
+
</LinkElement>
|
|
275
|
+
) : (
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
disabled={disabled}
|
|
279
|
+
aria-current={current ? "page" : undefined}
|
|
280
|
+
onClick={handleClick}
|
|
281
|
+
className={classes}
|
|
282
|
+
>
|
|
283
|
+
{content}
|
|
284
|
+
</button>
|
|
285
|
+
)}
|
|
286
|
+
</li>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
2
|
+
import { useControllableState } from "../../hooks/useControllableState";
|
|
3
|
+
import { ChevronLeft } from "../../icons/ChevronLeft";
|
|
4
|
+
import { ChevronRight } from "../../icons/ChevronRight";
|
|
5
|
+
import { cn } from "../../lib/cn";
|
|
6
|
+
import { IconButton, type IconButtonSize } from "../icon-button/IconButton";
|
|
7
|
+
|
|
8
|
+
export type NavigatorSize = "sm" | "md";
|
|
9
|
+
|
|
10
|
+
export type NavigatorProps = Omit<
|
|
11
|
+
ComponentProps<"div">,
|
|
12
|
+
"children" | "onChange"
|
|
13
|
+
> & {
|
|
14
|
+
/** 各条目的名称,按顺序 */
|
|
15
|
+
items: ReactNode[];
|
|
16
|
+
/** 受控的当前条目,从 0 起 */
|
|
17
|
+
index?: number;
|
|
18
|
+
/** 非受控时的初始条目,默认 0 */
|
|
19
|
+
defaultIndex?: number;
|
|
20
|
+
onIndexChange?: (index: number) => void;
|
|
21
|
+
/** 首尾相接:到最后一项再往后回到第一项。默认到头时按钮禁用 */
|
|
22
|
+
loop?: boolean;
|
|
23
|
+
/** 40 或 56px 高 */
|
|
24
|
+
size?: NavigatorSize;
|
|
25
|
+
prevLabel?: string;
|
|
26
|
+
nextLabel?: string;
|
|
27
|
+
/** 读屏听到的当前位置,默认"第 1 项,共 4 项" */
|
|
28
|
+
positionLabel?: (position: number, count: number) => string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const boxSize: Record<NavigatorSize, string> = {
|
|
32
|
+
sm: "h-10 gap-1 p-1 text-sm",
|
|
33
|
+
md: "h-14 gap-2 p-2 text-base",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const buttonSize: Record<NavigatorSize, IconButtonSize> = {
|
|
37
|
+
sm: "sm",
|
|
38
|
+
md: "md",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const defaultPositionLabel = (position: number, count: number) =>
|
|
42
|
+
`第 ${position} 项,共 ${count} 项`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 胶囊导航器:在少量条目间前后切换,并显示当前条目的名称。
|
|
46
|
+
* 用 `aria-label` 说明翻的是什么("勘探区")。
|
|
47
|
+
*/
|
|
48
|
+
export function Navigator({
|
|
49
|
+
items,
|
|
50
|
+
index,
|
|
51
|
+
defaultIndex = 0,
|
|
52
|
+
onIndexChange,
|
|
53
|
+
loop = false,
|
|
54
|
+
size = "md",
|
|
55
|
+
prevLabel = "上一项",
|
|
56
|
+
nextLabel = "下一项",
|
|
57
|
+
positionLabel = defaultPositionLabel,
|
|
58
|
+
className,
|
|
59
|
+
...props
|
|
60
|
+
}: NavigatorProps) {
|
|
61
|
+
const count = items.length;
|
|
62
|
+
const [inner, setIndex] = useControllableState({
|
|
63
|
+
value: index,
|
|
64
|
+
defaultValue: defaultIndex,
|
|
65
|
+
onChange: onIndexChange,
|
|
66
|
+
});
|
|
67
|
+
const current = Math.min(Math.max(inner, 0), Math.max(count - 1, 0));
|
|
68
|
+
|
|
69
|
+
const go = (next: number) => {
|
|
70
|
+
if (count === 0) return;
|
|
71
|
+
setIndex(
|
|
72
|
+
loop ? (next + count) % count : Math.min(Math.max(next, 0), count - 1),
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const single = count <= 1;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
role="group"
|
|
81
|
+
{...props}
|
|
82
|
+
data-size={size}
|
|
83
|
+
className={cn(
|
|
84
|
+
"inline-flex max-w-full items-center rounded-full bg-surface-muted text-ink",
|
|
85
|
+
boxSize[size],
|
|
86
|
+
className,
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<IconButton
|
|
90
|
+
variant="floating"
|
|
91
|
+
size={buttonSize[size]}
|
|
92
|
+
aria-label={prevLabel}
|
|
93
|
+
disabled={single || (!loop && current === 0)}
|
|
94
|
+
onClick={() => go(current - 1)}
|
|
95
|
+
>
|
|
96
|
+
<ChevronLeft />
|
|
97
|
+
</IconButton>
|
|
98
|
+
|
|
99
|
+
<div
|
|
100
|
+
aria-live="polite"
|
|
101
|
+
aria-atomic="true"
|
|
102
|
+
className="flex min-w-0 flex-1 items-baseline justify-center gap-2 px-2"
|
|
103
|
+
>
|
|
104
|
+
<span className="shrink-0 font-tech text-xs font-bold text-accent-ink tabular-nums">
|
|
105
|
+
<span aria-hidden="true">{`${count === 0 ? 0 : current + 1}/${count}`}</span>
|
|
106
|
+
<span className="sr-only">{positionLabel(current + 1, count)}</span>
|
|
107
|
+
</span>
|
|
108
|
+
{/* 所有名称叠在同一格里,按最宽的定宽:翻动时两端的按钮不跑位 */}
|
|
109
|
+
<span className="grid min-w-0">
|
|
110
|
+
{items.map((item, position) => (
|
|
111
|
+
<span
|
|
112
|
+
key={position}
|
|
113
|
+
data-current={position === current ? "" : undefined}
|
|
114
|
+
className={cn(
|
|
115
|
+
"col-start-1 row-start-1 truncate text-center",
|
|
116
|
+
position !== current && "invisible",
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{item}
|
|
120
|
+
</span>
|
|
121
|
+
))}
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<IconButton
|
|
126
|
+
variant="floating"
|
|
127
|
+
size={buttonSize[size]}
|
|
128
|
+
aria-label={nextLabel}
|
|
129
|
+
disabled={single || (!loop && current === count - 1)}
|
|
130
|
+
onClick={() => go(current + 1)}
|
|
131
|
+
>
|
|
132
|
+
<ChevronRight />
|
|
133
|
+
</IconButton>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
type ChangeEvent,
|
|
4
|
+
type ComponentProps,
|
|
5
|
+
type KeyboardEvent,
|
|
6
|
+
} from "react";
|
|
7
|
+
import { useControllableState } from "../../hooks/useControllableState";
|
|
8
|
+
import { ChevronLeft } from "../../icons/ChevronLeft";
|
|
9
|
+
import { ChevronRight } from "../../icons/ChevronRight";
|
|
10
|
+
import { cn } from "../../lib/cn";
|
|
11
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
12
|
+
import { IconButton, type IconButtonSize } from "../icon-button/IconButton";
|
|
13
|
+
|
|
14
|
+
export type PaginationSize = "md" | "lg";
|
|
15
|
+
|
|
16
|
+
export type PaginationProps = Omit<
|
|
17
|
+
ComponentProps<"nav">,
|
|
18
|
+
"children" | "onChange"
|
|
19
|
+
> & {
|
|
20
|
+
/** 受控的当前页,从 1 起 */
|
|
21
|
+
page?: number;
|
|
22
|
+
/** 非受控时的初始页,默认 1 */
|
|
23
|
+
defaultPage?: number;
|
|
24
|
+
onPageChange?: (page: number) => void;
|
|
25
|
+
/** 总页数 */
|
|
26
|
+
pageCount: number;
|
|
27
|
+
/** 页码处换成输入框:输入页码后回车或失焦跳页,Esc 还原 */
|
|
28
|
+
jump?: boolean;
|
|
29
|
+
/** 条高 48 或 56px */
|
|
30
|
+
size?: PaginationSize;
|
|
31
|
+
prevLabel?: string;
|
|
32
|
+
nextLabel?: string;
|
|
33
|
+
/** 跳页输入框的可访问名称 */
|
|
34
|
+
jumpLabel?: string;
|
|
35
|
+
/** 读屏听到的当前位置,默认"第 1 页,共 12 页" */
|
|
36
|
+
pageLabel?: (page: number, pageCount: number) => string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const barSize: Record<PaginationSize, string> = {
|
|
40
|
+
md: "h-12",
|
|
41
|
+
lg: "h-14",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/* 方钮比条高小 8px */
|
|
45
|
+
const buttonSize: Record<PaginationSize, IconButtonSize> = {
|
|
46
|
+
md: "md",
|
|
47
|
+
lg: "lg",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const defaultPageLabel = (page: number, pageCount: number) =>
|
|
51
|
+
`第 ${page} 页,共 ${pageCount} 页`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 分页条:浅灰斜纹条,两端是方钮,中间是 `01 / 12`。
|
|
55
|
+
* 不列一长串数字按钮;要跳页就打开 `jump`,在页码处直接输入。
|
|
56
|
+
*/
|
|
57
|
+
export function Pagination({
|
|
58
|
+
page,
|
|
59
|
+
defaultPage = 1,
|
|
60
|
+
onPageChange,
|
|
61
|
+
pageCount,
|
|
62
|
+
jump = false,
|
|
63
|
+
size = "md",
|
|
64
|
+
prevLabel = "上一页",
|
|
65
|
+
nextLabel = "下一页",
|
|
66
|
+
jumpLabel = "跳到第几页",
|
|
67
|
+
pageLabel = defaultPageLabel,
|
|
68
|
+
className,
|
|
69
|
+
...props
|
|
70
|
+
}: PaginationProps) {
|
|
71
|
+
const total = Math.max(1, Math.floor(pageCount));
|
|
72
|
+
const clamp = (next: number) => Math.min(Math.max(next, 1), total);
|
|
73
|
+
|
|
74
|
+
const [inner, setPage] = useControllableState({
|
|
75
|
+
value: page,
|
|
76
|
+
defaultValue: defaultPage,
|
|
77
|
+
onChange: onPageChange,
|
|
78
|
+
});
|
|
79
|
+
const current = clamp(inner);
|
|
80
|
+
|
|
81
|
+
// 输入到一半的页码:不为 null 时输入框显示它
|
|
82
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
83
|
+
|
|
84
|
+
// 至少两位,补零;总页数上百时跟着加宽
|
|
85
|
+
const digits = Math.max(2, String(total).length);
|
|
86
|
+
const pad = (value: number) => String(value).padStart(digits, "0");
|
|
87
|
+
|
|
88
|
+
const go = (next: number) => setPage(clamp(next));
|
|
89
|
+
|
|
90
|
+
const commitDraft = () => {
|
|
91
|
+
if (draft === null) return;
|
|
92
|
+
const typed = Number.parseInt(draft, 10);
|
|
93
|
+
setDraft(null);
|
|
94
|
+
if (Number.isFinite(typed)) go(typed);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
98
|
+
setDraft(event.target.value.replace(/\D/g, "").slice(0, digits + 1));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
102
|
+
if (event.key === "Enter") {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
commitDraft();
|
|
105
|
+
} else if (event.key === "Escape") {
|
|
106
|
+
setDraft(null);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const squareClass = "bg-surface-raised shadow-sm";
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<nav
|
|
114
|
+
aria-label="分页"
|
|
115
|
+
{...props}
|
|
116
|
+
data-size={size}
|
|
117
|
+
className={cn(
|
|
118
|
+
"@container hatch flex items-center justify-between gap-2 bg-surface-muted px-1 text-ink",
|
|
119
|
+
barSize[size],
|
|
120
|
+
className,
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
<IconButton
|
|
124
|
+
variant="accent"
|
|
125
|
+
size={buttonSize[size]}
|
|
126
|
+
aria-label={prevLabel}
|
|
127
|
+
disabled={current <= 1}
|
|
128
|
+
onClick={() => go(current - 1)}
|
|
129
|
+
className={squareClass}
|
|
130
|
+
>
|
|
131
|
+
<ChevronLeft />
|
|
132
|
+
</IconButton>
|
|
133
|
+
|
|
134
|
+
{/* 很窄的容器里页码小一档,三位数的页码才放得下 */}
|
|
135
|
+
<div className="flex min-w-0 items-center gap-2 font-display text-base leading-none whitespace-nowrap tabular-nums @2xs:text-lg">
|
|
136
|
+
{/* 看得见的 01 / 12 对读屏隐藏,换成一句完整的话,翻页时播报 */}
|
|
137
|
+
<span className="sr-only" aria-live="polite">
|
|
138
|
+
{pageLabel(current, total)}
|
|
139
|
+
</span>
|
|
140
|
+
{jump ? (
|
|
141
|
+
<input
|
|
142
|
+
type="text"
|
|
143
|
+
inputMode="numeric"
|
|
144
|
+
autoComplete="off"
|
|
145
|
+
aria-label={jumpLabel}
|
|
146
|
+
value={draft ?? pad(current)}
|
|
147
|
+
onChange={handleChange}
|
|
148
|
+
onKeyDown={handleKeyDown}
|
|
149
|
+
onBlur={commitDraft}
|
|
150
|
+
onFocus={(event) => event.target.select()}
|
|
151
|
+
style={{ width: `calc(${digits}ch + 0.75rem)` }}
|
|
152
|
+
className={cn(
|
|
153
|
+
"h-8 border-b-2 border-line-strong bg-surface-raised text-center font-bold text-ink",
|
|
154
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
155
|
+
"hover:not-focus:border-ink-secondary focus:border-ink",
|
|
156
|
+
focusRing,
|
|
157
|
+
)}
|
|
158
|
+
/>
|
|
159
|
+
) : (
|
|
160
|
+
<span aria-hidden="true" className="font-bold">
|
|
161
|
+
{pad(current)}
|
|
162
|
+
</span>
|
|
163
|
+
)}
|
|
164
|
+
<span aria-hidden="true" className="font-medium text-ink-secondary">
|
|
165
|
+
{`/ ${pad(total)}`}
|
|
166
|
+
</span>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<IconButton
|
|
170
|
+
variant="accent"
|
|
171
|
+
size={buttonSize[size]}
|
|
172
|
+
aria-label={nextLabel}
|
|
173
|
+
disabled={current >= total}
|
|
174
|
+
onClick={() => go(current + 1)}
|
|
175
|
+
className={squareClass}
|
|
176
|
+
>
|
|
177
|
+
<ChevronRight />
|
|
178
|
+
</IconButton>
|
|
179
|
+
</nav>
|
|
180
|
+
);
|
|
181
|
+
}
|