@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,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,211 @@
|
|
|
1
|
+
import { OTPField as BaseOtpField } from "@base-ui/react/otp-field";
|
|
2
|
+
import { Fragment, useId, type ComponentProps } from "react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
5
|
+
import { useFieldContext, useFieldControl } from "../field/Field";
|
|
6
|
+
import type { ControlSize, ControlVariant } from "../input/control-box";
|
|
7
|
+
|
|
8
|
+
export type OtpInputType = "numeric" | "alphanumeric";
|
|
9
|
+
export type OtpInputSize = ControlSize;
|
|
10
|
+
export type OtpInputVariant = ControlVariant;
|
|
11
|
+
|
|
12
|
+
export type OtpInputProps = Omit<
|
|
13
|
+
ComponentProps<"div">,
|
|
14
|
+
"onChange" | "defaultValue" | "children"
|
|
15
|
+
> & {
|
|
16
|
+
/** 一共几位,默认 6 */
|
|
17
|
+
length?: number;
|
|
18
|
+
/** 拼起来的那一串 */
|
|
19
|
+
value?: string;
|
|
20
|
+
defaultValue?: string;
|
|
21
|
+
onValueChange?: (value: string) => void;
|
|
22
|
+
/** 每一位都填上了。粘贴一整串也算 */
|
|
23
|
+
onComplete?: (value: string) => void;
|
|
24
|
+
/** 收哪些字符:`numeric` 只收数字,默认;`alphanumeric` 收字母和数字 */
|
|
25
|
+
type?: OtpInputType;
|
|
26
|
+
/** 字母统一成大写:值也是大写,不只是看起来 */
|
|
27
|
+
uppercase?: boolean;
|
|
28
|
+
/** 把填进去的字遮成圆点 */
|
|
29
|
+
mask?: boolean;
|
|
30
|
+
/** 每几格之间加一道短横,如 6 位的码写 `3` 分成两组。只是画的 */
|
|
31
|
+
groupSize?: number;
|
|
32
|
+
/** 32 / 40 / 56px 见方 */
|
|
33
|
+
size?: OtpInputSize;
|
|
34
|
+
/** 同输入框:`sunken` 凹陷的底 + 底边线,默认;`outline` 四边描边 */
|
|
35
|
+
variant?: OtpInputVariant;
|
|
36
|
+
/** 错误态:每一格的边线变红。放在 `Field` 里时跟随它的 `error` */
|
|
37
|
+
invalid?: boolean;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
readOnly?: boolean;
|
|
40
|
+
required?: boolean;
|
|
41
|
+
/** 传了就有一个隐藏字段随表单提交,值是拼起来的那一串 */
|
|
42
|
+
name?: string;
|
|
43
|
+
form?: string;
|
|
44
|
+
/** 填满就提交所在的表单 */
|
|
45
|
+
autoSubmit?: boolean;
|
|
46
|
+
/** 挂载后焦点落在第一格 */
|
|
47
|
+
autoFocus?: boolean;
|
|
48
|
+
/** 第二格起每一格的可访问名称,默认"第 2 位,共 6 位"。第一格的名称是字段的标签 */
|
|
49
|
+
slotLabel?: (position: number, length: number) => string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/* 格子见方;容器窄了宽度可以让,高度不变 */
|
|
53
|
+
const slotSize: Record<ControlSize, string> = {
|
|
54
|
+
sm: "h-8 w-8 text-base",
|
|
55
|
+
md: "h-10 w-10 text-lg",
|
|
56
|
+
lg: "h-14 w-14 text-2xl",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const slotFrame: Record<ControlVariant, string> = {
|
|
60
|
+
sunken: "border-b-2",
|
|
61
|
+
outline: "border",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const slotFill: Record<ControlVariant, string> = {
|
|
65
|
+
sunken: "bg-surface-sunken",
|
|
66
|
+
outline: "bg-surface",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const gapClass: Record<ControlSize, string> = {
|
|
70
|
+
sm: "gap-1.5",
|
|
71
|
+
md: "gap-2",
|
|
72
|
+
lg: "gap-2",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const defaultSlotLabel = (position: number, length: number) =>
|
|
76
|
+
`第 ${position} 位,共 ${length} 位`;
|
|
77
|
+
|
|
78
|
+
const toUpperCase = (value: string) => value.toUpperCase();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 验证码输入:一格一位的短码。每一格是一个真的输入框——打一位跳一格,
|
|
82
|
+
* 粘贴一整串会分到各格,整组在 Tab 顺序里只停一次。
|
|
83
|
+
*
|
|
84
|
+
* 每个都要有标签:放进 `Field`,或者自己传 `aria-label`。
|
|
85
|
+
* `className` 与 `ref` 给外面那一排。
|
|
86
|
+
*/
|
|
87
|
+
export function OtpInput({
|
|
88
|
+
length = 6,
|
|
89
|
+
value,
|
|
90
|
+
defaultValue,
|
|
91
|
+
onValueChange,
|
|
92
|
+
onComplete,
|
|
93
|
+
type = "numeric",
|
|
94
|
+
uppercase = false,
|
|
95
|
+
mask = false,
|
|
96
|
+
groupSize,
|
|
97
|
+
size = "md",
|
|
98
|
+
variant = "sunken",
|
|
99
|
+
invalid: invalidProp,
|
|
100
|
+
id: idProp,
|
|
101
|
+
disabled: disabledProp,
|
|
102
|
+
required: requiredProp,
|
|
103
|
+
readOnly = false,
|
|
104
|
+
name,
|
|
105
|
+
form,
|
|
106
|
+
autoSubmit = false,
|
|
107
|
+
autoFocus = false,
|
|
108
|
+
slotLabel = defaultSlotLabel,
|
|
109
|
+
"aria-label": ariaLabel,
|
|
110
|
+
"aria-labelledby": ariaLabelledBy,
|
|
111
|
+
"aria-describedby": describedByProp,
|
|
112
|
+
className,
|
|
113
|
+
...props
|
|
114
|
+
}: OtpInputProps) {
|
|
115
|
+
const fieldContext = useFieldContext();
|
|
116
|
+
const { id, disabled, required, invalid, ...field } = useFieldControl({
|
|
117
|
+
id: idProp,
|
|
118
|
+
disabled: disabledProp,
|
|
119
|
+
required: requiredProp,
|
|
120
|
+
invalid: invalidProp,
|
|
121
|
+
"aria-describedby": describedByProp,
|
|
122
|
+
});
|
|
123
|
+
const describedBy = field["aria-describedby"];
|
|
124
|
+
|
|
125
|
+
// 基元不认第一格上的 aria-label(它要一个真的标签):自己传名称时,
|
|
126
|
+
// 把它写成一段隐藏的文字,让第一格和整组都指过去
|
|
127
|
+
const labelId = useId();
|
|
128
|
+
const ownLabel = ariaLabelledBy ?? (ariaLabel ? labelId : undefined);
|
|
129
|
+
const groupLabel = ownLabel ?? fieldContext?.labelId;
|
|
130
|
+
|
|
131
|
+
const slotClass = cn(
|
|
132
|
+
"min-w-0 shrink text-center font-tech font-medium tabular-nums text-ink caret-ink",
|
|
133
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
134
|
+
focusRing,
|
|
135
|
+
slotSize[size],
|
|
136
|
+
slotFrame[variant],
|
|
137
|
+
readOnly
|
|
138
|
+
? // 只读:没有边线、底变透明。边线留透明的,尺寸不跳
|
|
139
|
+
"border-transparent bg-transparent"
|
|
140
|
+
: slotFill[variant],
|
|
141
|
+
disabled
|
|
142
|
+
? "cursor-not-allowed border-line text-ink-disabled"
|
|
143
|
+
: invalid
|
|
144
|
+
? "border-danger"
|
|
145
|
+
: !readOnly &&
|
|
146
|
+
// 悬停只在没聚焦时生效:聚焦中的格子不会因为鼠标经过而变浅
|
|
147
|
+
"border-line-strong hover:not-focus:border-ink-secondary focus:border-ink",
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const group = groupSize !== undefined && groupSize > 0 ? groupSize : 0;
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<BaseOtpField.Root
|
|
154
|
+
{...props}
|
|
155
|
+
id={id}
|
|
156
|
+
length={length}
|
|
157
|
+
value={value}
|
|
158
|
+
defaultValue={defaultValue}
|
|
159
|
+
onValueChange={onValueChange && ((next) => onValueChange(next))}
|
|
160
|
+
onValueComplete={onComplete && ((next) => onComplete(next))}
|
|
161
|
+
validationType={type}
|
|
162
|
+
normalizeValue={uppercase ? toUpperCase : undefined}
|
|
163
|
+
mask={mask}
|
|
164
|
+
disabled={disabled}
|
|
165
|
+
readOnly={readOnly}
|
|
166
|
+
required={required}
|
|
167
|
+
name={name}
|
|
168
|
+
form={form}
|
|
169
|
+
autoSubmit={autoSubmit}
|
|
170
|
+
aria-labelledby={groupLabel}
|
|
171
|
+
aria-describedby={describedBy}
|
|
172
|
+
data-size={size}
|
|
173
|
+
data-variant={variant}
|
|
174
|
+
data-invalid={invalid ? "" : undefined}
|
|
175
|
+
className={cn(
|
|
176
|
+
// 不许比容器宽:放在"靠左对齐"的弹性容器里时它是按内容定宽的,
|
|
177
|
+
// 没有这一条格子就不会收窄,而是整排伸出去
|
|
178
|
+
"flex max-w-full min-w-0 items-center",
|
|
179
|
+
gapClass[size],
|
|
180
|
+
className,
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{ariaLabel && !ariaLabelledBy && (
|
|
184
|
+
<span id={labelId} hidden>
|
|
185
|
+
{ariaLabel}
|
|
186
|
+
</span>
|
|
187
|
+
)}
|
|
188
|
+
{Array.from({ length }, (_, index) => (
|
|
189
|
+
<Fragment key={index}>
|
|
190
|
+
{group > 0 && index > 0 && index % group === 0 && (
|
|
191
|
+
<span
|
|
192
|
+
aria-hidden="true"
|
|
193
|
+
data-separator=""
|
|
194
|
+
className="h-0.5 w-2 shrink-0 bg-line-strong"
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
<BaseOtpField.Input
|
|
198
|
+
// 第一格的名称是字段的标签(或者上面那段隐藏的文字),说明文字也挂在它上面
|
|
199
|
+
{...(index === 0
|
|
200
|
+
? { "aria-labelledby": ownLabel, "aria-describedby": describedBy }
|
|
201
|
+
: { "aria-label": slotLabel(index + 1, length) })}
|
|
202
|
+
aria-invalid={invalid || undefined}
|
|
203
|
+
autoFocus={autoFocus && index === 0}
|
|
204
|
+
autoCapitalize={uppercase ? "characters" : "none"}
|
|
205
|
+
className={slotClass}
|
|
206
|
+
/>
|
|
207
|
+
</Fragment>
|
|
208
|
+
))}
|
|
209
|
+
</BaseOtpField.Root>
|
|
210
|
+
);
|
|
211
|
+
}
|