@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,168 @@
|
|
|
1
|
+
import { Accordion as BaseAccordion } from "@base-ui/react/accordion";
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
useContext,
|
|
5
|
+
useMemo,
|
|
6
|
+
type ComponentProps,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { Minus } from "../../icons/Minus";
|
|
10
|
+
import { cn } from "../../lib/cn";
|
|
11
|
+
import { focusRingInset } from "../../lib/focus-ring";
|
|
12
|
+
|
|
13
|
+
export type AccordionSize = "sm" | "md";
|
|
14
|
+
export type AccordionLevel = 2 | 3 | 4 | 5 | 6;
|
|
15
|
+
|
|
16
|
+
const AccordionContext = createContext<{
|
|
17
|
+
size: AccordionSize;
|
|
18
|
+
level: AccordionLevel;
|
|
19
|
+
}>({ size: "md", level: 3 });
|
|
20
|
+
|
|
21
|
+
export type AccordionProps = Omit<
|
|
22
|
+
ComponentProps<"div">,
|
|
23
|
+
"defaultValue" | "onChange" | "style"
|
|
24
|
+
> & {
|
|
25
|
+
/** 可以同时展开几项。默认开了这个,那个就收起来 */
|
|
26
|
+
multiple?: boolean;
|
|
27
|
+
/** 展开着的那几项的 `value` */
|
|
28
|
+
value?: string[];
|
|
29
|
+
defaultValue?: string[];
|
|
30
|
+
onValueChange?: (value: string[]) => void;
|
|
31
|
+
/** 标题行高 40 / 48px,默认 `md` */
|
|
32
|
+
size?: AccordionSize;
|
|
33
|
+
/** 标题的层级,默认 3(`<h3>`)。按它在页面里的位置给 */
|
|
34
|
+
level?: AccordionLevel;
|
|
35
|
+
/**
|
|
36
|
+
* 收起的内容也留在页面里:浏览器的页内查找找得到,找到时自动展开。
|
|
37
|
+
* 常见问题这类"来找一句话"的内容值得开
|
|
38
|
+
*/
|
|
39
|
+
searchable?: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
/** `AccordionItem` */
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 折叠面板:一摞可以各自展开收起的小节。先问能不能不折叠——
|
|
47
|
+
* 藏起来的东西有人会找不到。
|
|
48
|
+
*/
|
|
49
|
+
export function Accordion({
|
|
50
|
+
multiple = false,
|
|
51
|
+
value,
|
|
52
|
+
defaultValue,
|
|
53
|
+
onValueChange,
|
|
54
|
+
size = "md",
|
|
55
|
+
level = 3,
|
|
56
|
+
searchable = false,
|
|
57
|
+
disabled,
|
|
58
|
+
className,
|
|
59
|
+
children,
|
|
60
|
+
...props
|
|
61
|
+
}: AccordionProps) {
|
|
62
|
+
const context = useMemo(() => ({ size, level }), [size, level]);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<AccordionContext value={context}>
|
|
66
|
+
<BaseAccordion.Root
|
|
67
|
+
{...props}
|
|
68
|
+
multiple={multiple}
|
|
69
|
+
value={value}
|
|
70
|
+
defaultValue={defaultValue}
|
|
71
|
+
onValueChange={
|
|
72
|
+
onValueChange && ((next) => onValueChange(next as string[]))
|
|
73
|
+
}
|
|
74
|
+
hiddenUntilFound={searchable}
|
|
75
|
+
disabled={disabled}
|
|
76
|
+
data-size={size}
|
|
77
|
+
className={cn("flex flex-col border-t border-line text-ink", className)}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</BaseAccordion.Root>
|
|
81
|
+
</AccordionContext>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const triggerSize: Record<AccordionSize, string> = {
|
|
86
|
+
sm: "min-h-10 py-1.5 text-sm",
|
|
87
|
+
md: "min-h-12 py-2 text-base",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type ItemOwnProps = {
|
|
91
|
+
/** 这一项的标识:`Accordion` 的 `value` 里放的就是它 */
|
|
92
|
+
value: string;
|
|
93
|
+
/** 标题:写成"里面是什么" */
|
|
94
|
+
title: ReactNode;
|
|
95
|
+
/** 标题右侧、记号之前的补充:计数、一个标签。它在按钮里,不能放能点的东西 */
|
|
96
|
+
extra?: ReactNode;
|
|
97
|
+
disabled?: boolean;
|
|
98
|
+
/** 内容 */
|
|
99
|
+
children: ReactNode;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type AccordionItemProps = ItemOwnProps &
|
|
103
|
+
Omit<ComponentProps<"div">, keyof ItemOwnProps | "style">;
|
|
104
|
+
|
|
105
|
+
/** 折叠面板里的一节。 */
|
|
106
|
+
export function AccordionItem({
|
|
107
|
+
value,
|
|
108
|
+
title,
|
|
109
|
+
extra,
|
|
110
|
+
disabled,
|
|
111
|
+
className,
|
|
112
|
+
children,
|
|
113
|
+
...props
|
|
114
|
+
}: AccordionItemProps) {
|
|
115
|
+
const { size, level } = useContext(AccordionContext);
|
|
116
|
+
const Heading = `h${level}` as const;
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<BaseAccordion.Item
|
|
120
|
+
{...props}
|
|
121
|
+
value={value}
|
|
122
|
+
disabled={disabled}
|
|
123
|
+
className={cn("border-b border-line", className)}
|
|
124
|
+
>
|
|
125
|
+
<BaseAccordion.Header render={<Heading />} className="flex">
|
|
126
|
+
<BaseAccordion.Trigger
|
|
127
|
+
className={cn(
|
|
128
|
+
"group/trigger flex w-full items-center gap-3 px-4 text-left font-medium",
|
|
129
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
130
|
+
"hover:bg-ink/5 data-panel-open:font-bold",
|
|
131
|
+
"data-disabled:cursor-not-allowed data-disabled:text-ink-disabled data-disabled:hover:bg-transparent",
|
|
132
|
+
focusRingInset,
|
|
133
|
+
triggerSize[size],
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
<span className="min-w-0 flex-1 wrap-anywhere">{title}</span>
|
|
137
|
+
{extra && (
|
|
138
|
+
<span className="flex shrink-0 items-center gap-2 text-sm font-normal text-ink-secondary group-data-disabled/trigger:text-ink-disabled">
|
|
139
|
+
{extra}
|
|
140
|
+
</span>
|
|
141
|
+
)}
|
|
142
|
+
{/* 加号:两笔叠着,竖的那一笔在展开时转平,和横的重合成减号 */}
|
|
143
|
+
<span
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
className="relative flex size-4 shrink-0 items-center justify-center text-ink-secondary group-data-disabled/trigger:text-ink-disabled"
|
|
146
|
+
>
|
|
147
|
+
<Minus size={16} />
|
|
148
|
+
<Minus
|
|
149
|
+
size={16}
|
|
150
|
+
className="absolute rotate-90 transition-transform duration-(--duration-fast) ease-standard group-data-panel-open/trigger:rotate-0"
|
|
151
|
+
/>
|
|
152
|
+
</span>
|
|
153
|
+
</BaseAccordion.Trigger>
|
|
154
|
+
</BaseAccordion.Header>
|
|
155
|
+
<BaseAccordion.Panel
|
|
156
|
+
className={cn(
|
|
157
|
+
"h-(--accordion-panel-height) overflow-hidden",
|
|
158
|
+
"transition-[height] duration-(--duration-base) ease-standard",
|
|
159
|
+
"data-ending-style:h-0 data-starting-style:h-0",
|
|
160
|
+
)}
|
|
161
|
+
>
|
|
162
|
+
<div className={cn("px-4 pb-4", size === "sm" && "text-sm")}>
|
|
163
|
+
{children}
|
|
164
|
+
</div>
|
|
165
|
+
</BaseAccordion.Panel>
|
|
166
|
+
</BaseAccordion.Item>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ComponentProps, ComponentType, ReactNode } from "react";
|
|
2
|
+
import { Close } from "../../icons/Close";
|
|
3
|
+
import type { IconProps } from "../../icons/createIcon";
|
|
4
|
+
import { StatusDanger } from "../../icons/StatusDanger";
|
|
5
|
+
import { StatusInfo } from "../../icons/StatusInfo";
|
|
6
|
+
import { StatusSuccess } from "../../icons/StatusSuccess";
|
|
7
|
+
import { StatusWarning } from "../../icons/StatusWarning";
|
|
8
|
+
import { cn } from "../../lib/cn";
|
|
9
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
10
|
+
|
|
11
|
+
export type AlertTone = "info" | "success" | "warning" | "danger";
|
|
12
|
+
|
|
13
|
+
export type AlertProps = Omit<ComponentProps<"div">, "title"> & {
|
|
14
|
+
/** 色调只点在左缘色条和图标上,底色保持中性。默认 `info` */
|
|
15
|
+
tone?: AlertTone;
|
|
16
|
+
/** 加粗的一行标题,可省 */
|
|
17
|
+
title?: ReactNode;
|
|
18
|
+
/** 右侧的一个操作,通常是 `text` 按钮 */
|
|
19
|
+
action?: ReactNode;
|
|
20
|
+
/** 传了就出现关闭图标。提示条自己不隐藏,由使用方决定 */
|
|
21
|
+
onClose?: () => void;
|
|
22
|
+
/** 关闭图标的可访问名称 */
|
|
23
|
+
closeLabel?: string;
|
|
24
|
+
/** 替换默认图标;传 `null` 去掉 */
|
|
25
|
+
icon?: ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/* 四种色调用四种不同外形的图标:方框、圆、三角、菱形 */
|
|
29
|
+
const toneIcon: Record<AlertTone, ComponentType<IconProps>> = {
|
|
30
|
+
info: StatusInfo,
|
|
31
|
+
success: StatusSuccess,
|
|
32
|
+
warning: StatusWarning,
|
|
33
|
+
danger: StatusDanger,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const toneBar: Record<AlertTone, string> = {
|
|
37
|
+
info: "border-l-info",
|
|
38
|
+
success: "border-l-success",
|
|
39
|
+
warning: "border-l-warning",
|
|
40
|
+
danger: "border-l-danger",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const toneText: Record<AlertTone, string> = {
|
|
44
|
+
info: "text-info",
|
|
45
|
+
success: "text-success",
|
|
46
|
+
warning: "text-warning",
|
|
47
|
+
danger: "text-danger",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** 页面内的一条常驻消息。需要用户处理的重要信息用它,不用会消失的轻提示。 */
|
|
51
|
+
export function Alert({
|
|
52
|
+
tone = "info",
|
|
53
|
+
title,
|
|
54
|
+
action,
|
|
55
|
+
onClose,
|
|
56
|
+
closeLabel = "关闭",
|
|
57
|
+
icon,
|
|
58
|
+
className,
|
|
59
|
+
children,
|
|
60
|
+
...props
|
|
61
|
+
}: AlertProps) {
|
|
62
|
+
const Icon = toneIcon[tone];
|
|
63
|
+
const graphic = icon === undefined ? <Icon size={20} /> : icon;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
role={tone === "danger" || tone === "warning" ? "alert" : "status"}
|
|
68
|
+
{...props}
|
|
69
|
+
data-tone={tone}
|
|
70
|
+
className={cn(
|
|
71
|
+
"flex items-start gap-3 border border-l-4 border-line bg-surface-raised py-3 pr-3 pl-3 text-sm text-ink",
|
|
72
|
+
toneBar[tone],
|
|
73
|
+
className,
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{graphic !== null && (
|
|
77
|
+
<span aria-hidden="true" className={cn("shrink-0", toneText[tone])}>
|
|
78
|
+
{graphic}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
<div className="min-w-0 flex-1 wrap-anywhere">
|
|
82
|
+
{title && <p className="font-medium">{title}</p>}
|
|
83
|
+
{children && (
|
|
84
|
+
<div className={cn(title && "mt-0.5 text-ink-secondary")}>
|
|
85
|
+
{children}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
{action && <div className="shrink-0">{action}</div>}
|
|
90
|
+
{onClose && (
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
aria-label={closeLabel}
|
|
94
|
+
onClick={onClose}
|
|
95
|
+
className={cn(
|
|
96
|
+
"-my-1.5 -mr-1 inline-flex size-8 shrink-0 items-center justify-center text-ink-secondary hover:bg-ink/5 hover:text-ink active:bg-ink/10",
|
|
97
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
98
|
+
focusRing,
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
<Close size={16} />
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Alert, type AlertProps, type AlertTone } from "./Alert";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Avatar as BaseAvatar } from "@base-ui/react/avatar";
|
|
2
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
|
|
5
|
+
export type AvatarSize = "sm" | "md" | "lg" | "xl";
|
|
6
|
+
|
|
7
|
+
export type AvatarProps = Omit<ComponentProps<"span">, "style"> & {
|
|
8
|
+
/** 名字。没有图时显示它的首字;也是头像默认的可访问名称 */
|
|
9
|
+
name?: string;
|
|
10
|
+
/** 图片地址。没给、没加载出来、加载失败时显示首字 */
|
|
11
|
+
src?: string;
|
|
12
|
+
/**
|
|
13
|
+
* 可访问名称,默认取 `name`。
|
|
14
|
+
* 旁边已经写了名字时传 `""`:头像变成纯装饰,对读屏隐藏
|
|
15
|
+
*/
|
|
16
|
+
alt?: string;
|
|
17
|
+
/** 32 / 40 / 56 / 80px,默认 `md` */
|
|
18
|
+
size?: AvatarSize;
|
|
19
|
+
/** 选中:外面一圈行动色的环 */
|
|
20
|
+
selected?: boolean;
|
|
21
|
+
/** 没有图时显示的东西,代替首字(比如一个图标) */
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const sizeClass: Record<AvatarSize, string> = {
|
|
26
|
+
sm: "size-8 text-xs",
|
|
27
|
+
md: "size-10 text-sm",
|
|
28
|
+
lg: "size-14 text-lg",
|
|
29
|
+
xl: "size-20 text-2xl",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* 选中环:2px 的间隔 + 3px 的行动色 + 1px 的墨线。
|
|
34
|
+
* 墨线是给亮色页面的——黄色在白底上看不出来
|
|
35
|
+
*/
|
|
36
|
+
export const avatarSelectedRing =
|
|
37
|
+
"shadow-[0_0_0_2px_var(--ef-surface),0_0_0_5px_var(--ef-action),0_0_0_6px_var(--ef-ink)]";
|
|
38
|
+
|
|
39
|
+
/** 中文名取第一个字;拉丁字母的名字取前两个词的首字母 */
|
|
40
|
+
export function initialsOf(name: string): string {
|
|
41
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
42
|
+
const first = [...(words[0] ?? "")][0];
|
|
43
|
+
if (!first) return "";
|
|
44
|
+
if (!/[a-z]/i.test(first)) return first;
|
|
45
|
+
const second = words.length > 1 ? ([...words[1]!][0] ?? "") : "";
|
|
46
|
+
return (first + second).toUpperCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 头像:代表一个人或一个角色的圆形图。自己不能点。 */
|
|
50
|
+
export function Avatar({
|
|
51
|
+
name,
|
|
52
|
+
src,
|
|
53
|
+
alt = name ?? "",
|
|
54
|
+
size = "md",
|
|
55
|
+
selected = false,
|
|
56
|
+
className,
|
|
57
|
+
children,
|
|
58
|
+
...props
|
|
59
|
+
}: AvatarProps) {
|
|
60
|
+
const decorative = alt === "";
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<BaseAvatar.Root
|
|
64
|
+
{...props}
|
|
65
|
+
// 首字和图都只是这个名字的两种画法:整体是一张以名字命名的图
|
|
66
|
+
{...(decorative
|
|
67
|
+
? { "aria-hidden": true }
|
|
68
|
+
: { role: "img", "aria-label": alt })}
|
|
69
|
+
data-size={size}
|
|
70
|
+
data-selected={selected ? "" : undefined}
|
|
71
|
+
className={cn(
|
|
72
|
+
"relative inline-flex shrink-0 items-center justify-center overflow-clip rounded-full bg-surface-muted font-medium text-ink-secondary select-none",
|
|
73
|
+
sizeClass[size],
|
|
74
|
+
selected && avatarSelectedRing,
|
|
75
|
+
className,
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{src && (
|
|
79
|
+
<BaseAvatar.Image src={src} alt="" className="size-full object-cover" />
|
|
80
|
+
)}
|
|
81
|
+
<BaseAvatar.Fallback
|
|
82
|
+
aria-hidden="true"
|
|
83
|
+
className="flex size-full items-center justify-center leading-none [&_svg]:size-1/2"
|
|
84
|
+
>
|
|
85
|
+
{children ?? (name ? initialsOf(name) : null)}
|
|
86
|
+
</BaseAvatar.Fallback>
|
|
87
|
+
</BaseAvatar.Root>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
isValidElement,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
type ComponentProps,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { useControllableState } from "../../hooks/useControllableState";
|
|
14
|
+
import { ChevronDown } from "../../icons/ChevronDown";
|
|
15
|
+
import { ChevronLeft } from "../../icons/ChevronLeft";
|
|
16
|
+
import { ChevronRight } from "../../icons/ChevronRight";
|
|
17
|
+
import { cn } from "../../lib/cn";
|
|
18
|
+
import { IconButton } from "../icon-button/IconButton";
|
|
19
|
+
import { Avatar, type AvatarSize } from "./Avatar";
|
|
20
|
+
|
|
21
|
+
export type AvatarSwitcherOrientation = "vertical" | "horizontal";
|
|
22
|
+
|
|
23
|
+
type SwitcherContextValue = {
|
|
24
|
+
name: string;
|
|
25
|
+
value: string | null;
|
|
26
|
+
size: AvatarSize;
|
|
27
|
+
select: (value: string) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SwitcherContext = createContext<SwitcherContextValue | null>(null);
|
|
31
|
+
|
|
32
|
+
export type AvatarSwitcherProps = Omit<
|
|
33
|
+
ComponentProps<"div">,
|
|
34
|
+
"aria-label" | "defaultValue" | "onChange"
|
|
35
|
+
> & {
|
|
36
|
+
/** 选的是什么,比如"人员" */
|
|
37
|
+
"aria-label": string;
|
|
38
|
+
/** 选中的那一个的 `value` */
|
|
39
|
+
value?: string;
|
|
40
|
+
defaultValue?: string;
|
|
41
|
+
onValueChange?: (value: string) => void;
|
|
42
|
+
/** 竖排(默认)或横排 */
|
|
43
|
+
orientation?: AvatarSwitcherOrientation;
|
|
44
|
+
/** 头像的大小,默认 `lg`(56px) */
|
|
45
|
+
size?: AvatarSize;
|
|
46
|
+
/** 翻页钮到头之后绕回另一头。默认到头时禁用 */
|
|
47
|
+
loop?: boolean;
|
|
48
|
+
/** 表单字段名:选中的值会随表单提交 */
|
|
49
|
+
name?: string;
|
|
50
|
+
previousLabel?: string;
|
|
51
|
+
nextLabel?: string;
|
|
52
|
+
/** 直接放 `AvatarSwitcherItem` */
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type ItemLike = { value?: unknown; disabled?: unknown };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 头像切换:一列圆形头像里选一个,两端各一个圆形翻页钮。
|
|
60
|
+
* 语义是单选组——方向键换人。头像放不下时给它一个高度(横排时是宽度)上限,
|
|
61
|
+
* 头像在两个翻页钮之间滚动。
|
|
62
|
+
*/
|
|
63
|
+
export function AvatarSwitcher({
|
|
64
|
+
value: valueProp,
|
|
65
|
+
defaultValue,
|
|
66
|
+
onValueChange,
|
|
67
|
+
orientation = "vertical",
|
|
68
|
+
size = "lg",
|
|
69
|
+
loop = false,
|
|
70
|
+
name: nameProp,
|
|
71
|
+
previousLabel = "上一个",
|
|
72
|
+
nextLabel = "下一个",
|
|
73
|
+
className,
|
|
74
|
+
children,
|
|
75
|
+
...props
|
|
76
|
+
}: AvatarSwitcherProps) {
|
|
77
|
+
const generatedName = useId();
|
|
78
|
+
const name = nameProp ?? generatedName;
|
|
79
|
+
const vertical = orientation === "vertical";
|
|
80
|
+
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
81
|
+
|
|
82
|
+
const [value, setValue] = useControllableState<string | null>({
|
|
83
|
+
value: valueProp,
|
|
84
|
+
defaultValue: defaultValue ?? null,
|
|
85
|
+
onChange: (next) => {
|
|
86
|
+
if (next !== null) onValueChange?.(next);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// 翻页钮要知道前后是谁:从子元素上读,所以子元素必须直接是 AvatarSwitcherItem
|
|
91
|
+
const values = Children.toArray(children).flatMap((child) => {
|
|
92
|
+
if (!isValidElement<ItemLike>(child)) return [];
|
|
93
|
+
const { value: itemValue, disabled } = child.props;
|
|
94
|
+
return typeof itemValue === "string" && !disabled ? [itemValue] : [];
|
|
95
|
+
});
|
|
96
|
+
const index = value === null ? -1 : values.indexOf(value);
|
|
97
|
+
const last = values.length - 1;
|
|
98
|
+
const step = (delta: 1 | -1) => {
|
|
99
|
+
if (values.length === 0) return;
|
|
100
|
+
const next =
|
|
101
|
+
index === -1
|
|
102
|
+
? delta === 1
|
|
103
|
+
? 0
|
|
104
|
+
: last
|
|
105
|
+
: loop
|
|
106
|
+
? (index + delta + values.length) % values.length
|
|
107
|
+
: Math.min(last, Math.max(0, index + delta));
|
|
108
|
+
setValue(values[next]!);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// 选中的那个滚进来。只动自己的滚动容器,不带着页面一起滚
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const scroller = scrollerRef.current;
|
|
114
|
+
const item = scroller?.querySelector<HTMLElement>("[data-current]");
|
|
115
|
+
if (!scroller || !item) return;
|
|
116
|
+
const start = vertical ? item.offsetTop : item.offsetLeft;
|
|
117
|
+
const length = vertical ? item.offsetHeight : item.offsetWidth;
|
|
118
|
+
const view = vertical ? scroller.clientHeight : scroller.clientWidth;
|
|
119
|
+
const scrolled = vertical ? scroller.scrollTop : scroller.scrollLeft;
|
|
120
|
+
// 留出选中环和焦点环的位置
|
|
121
|
+
const margin = 12;
|
|
122
|
+
let target = scrolled;
|
|
123
|
+
if (start - margin < scrolled) target = start - margin;
|
|
124
|
+
else if (start + length + margin > scrolled + view) {
|
|
125
|
+
target = start + length + margin - view;
|
|
126
|
+
}
|
|
127
|
+
if (target !== scrolled) {
|
|
128
|
+
scroller.scrollTo?.(vertical ? { top: target } : { left: target });
|
|
129
|
+
}
|
|
130
|
+
}, [value, vertical]);
|
|
131
|
+
|
|
132
|
+
const context = useMemo(
|
|
133
|
+
() => ({ name, value, size, select: setValue }),
|
|
134
|
+
[name, value, size, setValue],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const Previous = vertical ? ChevronDown : ChevronLeft;
|
|
138
|
+
const Next = vertical ? ChevronDown : ChevronRight;
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<SwitcherContext value={context}>
|
|
142
|
+
<div
|
|
143
|
+
{...props}
|
|
144
|
+
role="radiogroup"
|
|
145
|
+
aria-orientation={orientation}
|
|
146
|
+
data-orientation={orientation}
|
|
147
|
+
className={cn(
|
|
148
|
+
// 比容器宽的时候缩到容器的宽度:头像在两个翻页钮之间滚动,不撑破页面
|
|
149
|
+
"inline-flex max-w-full items-center gap-1",
|
|
150
|
+
vertical ? "flex-col" : "flex-row",
|
|
151
|
+
className,
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
{/* 翻页钮不占 Tab 顺序:键盘在单选组里用方向键就够了 */}
|
|
155
|
+
<IconButton
|
|
156
|
+
variant="floating"
|
|
157
|
+
size="sm"
|
|
158
|
+
tabIndex={-1}
|
|
159
|
+
aria-label={previousLabel}
|
|
160
|
+
disabled={!loop && index <= 0}
|
|
161
|
+
onClick={() => step(-1)}
|
|
162
|
+
>
|
|
163
|
+
<Previous className={vertical ? "rotate-180" : undefined} />
|
|
164
|
+
</IconButton>
|
|
165
|
+
<div
|
|
166
|
+
ref={scrollerRef}
|
|
167
|
+
className={cn(
|
|
168
|
+
// 内边距给选中环和焦点环留位置,否则会被滚动容器裁掉
|
|
169
|
+
"relative flex min-h-0 min-w-0 flex-1 gap-4 p-3 [scrollbar-width:none]",
|
|
170
|
+
vertical
|
|
171
|
+
? "flex-col overflow-x-hidden overflow-y-auto"
|
|
172
|
+
: "flex-row overflow-x-auto overflow-y-hidden",
|
|
173
|
+
)}
|
|
174
|
+
>
|
|
175
|
+
{children}
|
|
176
|
+
</div>
|
|
177
|
+
<IconButton
|
|
178
|
+
variant="floating"
|
|
179
|
+
size="sm"
|
|
180
|
+
tabIndex={-1}
|
|
181
|
+
aria-label={nextLabel}
|
|
182
|
+
disabled={!loop && index >= last}
|
|
183
|
+
onClick={() => step(1)}
|
|
184
|
+
>
|
|
185
|
+
<Next />
|
|
186
|
+
</IconButton>
|
|
187
|
+
</div>
|
|
188
|
+
</SwitcherContext>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
type ItemOwnProps = {
|
|
193
|
+
value: string;
|
|
194
|
+
/** 名字:这一项的可访问名称,没有图时显示它的首字 */
|
|
195
|
+
label: string;
|
|
196
|
+
/** 头像的图片地址 */
|
|
197
|
+
src?: string;
|
|
198
|
+
disabled?: boolean;
|
|
199
|
+
/** 没有图时显示的东西,代替首字 */
|
|
200
|
+
children?: ReactNode;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export type AvatarSwitcherItemProps = ItemOwnProps &
|
|
204
|
+
Omit<ComponentProps<"label">, keyof ItemOwnProps>;
|
|
205
|
+
|
|
206
|
+
/** 头像切换里的一个头像。 */
|
|
207
|
+
export function AvatarSwitcherItem({
|
|
208
|
+
value,
|
|
209
|
+
label,
|
|
210
|
+
src,
|
|
211
|
+
disabled = false,
|
|
212
|
+
className,
|
|
213
|
+
children,
|
|
214
|
+
...props
|
|
215
|
+
}: AvatarSwitcherItemProps) {
|
|
216
|
+
const context = useContext(SwitcherContext);
|
|
217
|
+
if (!context) {
|
|
218
|
+
throw new Error("AvatarSwitcherItem 要放在 AvatarSwitcher 里面");
|
|
219
|
+
}
|
|
220
|
+
const checked = context.value === value;
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<label
|
|
224
|
+
{...props}
|
|
225
|
+
data-current={checked ? "" : undefined}
|
|
226
|
+
className={cn(
|
|
227
|
+
"relative flex shrink-0 rounded-full",
|
|
228
|
+
disabled ? "cursor-not-allowed" : "cursor-pointer",
|
|
229
|
+
className,
|
|
230
|
+
)}
|
|
231
|
+
>
|
|
232
|
+
<input
|
|
233
|
+
type="radio"
|
|
234
|
+
name={context.name}
|
|
235
|
+
value={value}
|
|
236
|
+
checked={checked}
|
|
237
|
+
disabled={disabled}
|
|
238
|
+
aria-label={label}
|
|
239
|
+
onChange={() => context.select(value)}
|
|
240
|
+
className="peer absolute inset-0 size-full cursor-[inherit] appearance-none rounded-full outline-none"
|
|
241
|
+
/>
|
|
242
|
+
<Avatar
|
|
243
|
+
name={label}
|
|
244
|
+
src={src}
|
|
245
|
+
alt=""
|
|
246
|
+
size={context.size}
|
|
247
|
+
selected={checked}
|
|
248
|
+
className={cn(
|
|
249
|
+
"pointer-events-none transition-opacity duration-(--duration-fast) ease-standard",
|
|
250
|
+
// 选中不只靠那一圈黄:其余的退后一档
|
|
251
|
+
!checked && "opacity-60 peer-hover:opacity-100",
|
|
252
|
+
disabled && "opacity-30 peer-hover:opacity-30",
|
|
253
|
+
// 焦点环画在选中环(6px)的外面,隔 1px
|
|
254
|
+
"peer-focus-visible:outline-2 peer-focus-visible:outline-offset-[7px] peer-focus-visible:outline-focus",
|
|
255
|
+
)}
|
|
256
|
+
>
|
|
257
|
+
{children}
|
|
258
|
+
</Avatar>
|
|
259
|
+
</label>
|
|
260
|
+
);
|
|
261
|
+
}
|