@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,35 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { decor } from "../../lib/decor";
|
|
4
|
+
|
|
5
|
+
export type HazardStripeSize = "sm" | "md";
|
|
6
|
+
|
|
7
|
+
export type HazardStripeProps = Omit<ComponentProps<"div">, "children"> & {
|
|
8
|
+
/** 条高 6px 或 12px */
|
|
9
|
+
size?: HazardStripeSize;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sizeClass: Record<HazardStripeSize, string> = {
|
|
13
|
+
sm: "h-1.5",
|
|
14
|
+
md: "h-3",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 警示条纹:黄黑相间的窄条,和红色一样带"危险 / 施工"的含义。
|
|
19
|
+
* 放在删除确认、不可逆操作这类区域的边缘;一个视口最多一处,普通内容不要用。
|
|
20
|
+
* 它只是装饰:危险的含义必须另外用文字写出来。
|
|
21
|
+
*/
|
|
22
|
+
export function HazardStripe({
|
|
23
|
+
size = "md",
|
|
24
|
+
className,
|
|
25
|
+
...props
|
|
26
|
+
}: HazardStripeProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
{...props}
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
// 有意不随主题变:警示条纹在哪种底色上都是黄黑两色
|
|
32
|
+
className={cn(decor, "hazard w-full", sizeClass[size], className)}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { PreviewCard as BasePreviewCard } from "@base-ui/react/preview-card";
|
|
2
|
+
import type { ComponentProps, ReactElement, ReactNode } from "react";
|
|
3
|
+
import { usePortalScope } from "../../hooks/usePortalScope";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import {
|
|
6
|
+
popoverDescription,
|
|
7
|
+
popoverPanel,
|
|
8
|
+
popoverTitle,
|
|
9
|
+
} from "../popover/panel-style";
|
|
10
|
+
|
|
11
|
+
export type HoverCardSide = "top" | "bottom" | "left" | "right";
|
|
12
|
+
export type HoverCardAlign = "start" | "center" | "end";
|
|
13
|
+
|
|
14
|
+
export type HoverCardProps = Omit<
|
|
15
|
+
ComponentProps<"div">,
|
|
16
|
+
"title" | "children" | "className" | "style"
|
|
17
|
+
> & {
|
|
18
|
+
/** 触发元素:一个链接(`<a>`、带 `href` 的按钮、路由库的链接组件) */
|
|
19
|
+
trigger: ReactElement<Record<string, unknown>>;
|
|
20
|
+
/** 卡片里的标题 */
|
|
21
|
+
title?: ReactNode;
|
|
22
|
+
/** 标题下的一行说明 */
|
|
23
|
+
description?: ReactNode;
|
|
24
|
+
/** 卡片出现在链接的哪一侧,放不下时自动翻到对面。默认 `bottom` */
|
|
25
|
+
side?: HoverCardSide;
|
|
26
|
+
/** 和链接的哪一边对齐。默认 `start` */
|
|
27
|
+
align?: HoverCardAlign;
|
|
28
|
+
/** 指针停住多久后出现,毫秒,默认 600。键盘聚焦时同样要等 */
|
|
29
|
+
delay?: number;
|
|
30
|
+
/** 指针离开多久后消失,毫秒,默认 300:够把指针移进卡片里 */
|
|
31
|
+
closeDelay?: number;
|
|
32
|
+
open?: boolean;
|
|
33
|
+
defaultOpen?: boolean;
|
|
34
|
+
onOpenChange?: (open: boolean) => void;
|
|
35
|
+
/** 给卡片 */
|
|
36
|
+
className?: string;
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 悬浮卡:指针停在一个链接上时,在旁边预览它的去处。面板和气泡卡片是同一块。
|
|
42
|
+
*
|
|
43
|
+
* 触屏上不出现,读屏也不会读到它:里面只放"点进去也看得到"的内容,
|
|
44
|
+
* 不放只有这里才有的信息和操作。要操作的用 `Popover`,一句说明用 `Tooltip`。
|
|
45
|
+
*/
|
|
46
|
+
export function HoverCard({
|
|
47
|
+
trigger,
|
|
48
|
+
title,
|
|
49
|
+
description,
|
|
50
|
+
side = "bottom",
|
|
51
|
+
align = "start",
|
|
52
|
+
delay = 600,
|
|
53
|
+
closeDelay = 300,
|
|
54
|
+
open,
|
|
55
|
+
defaultOpen,
|
|
56
|
+
onOpenChange,
|
|
57
|
+
className,
|
|
58
|
+
children,
|
|
59
|
+
...props
|
|
60
|
+
}: HoverCardProps) {
|
|
61
|
+
const { anchorRef, portalRef } = usePortalScope();
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<BasePreviewCard.Root
|
|
65
|
+
open={open}
|
|
66
|
+
defaultOpen={defaultOpen}
|
|
67
|
+
onOpenChange={onOpenChange && ((next) => onOpenChange(next))}
|
|
68
|
+
>
|
|
69
|
+
<BasePreviewCard.Trigger
|
|
70
|
+
ref={anchorRef}
|
|
71
|
+
render={trigger}
|
|
72
|
+
delay={delay}
|
|
73
|
+
closeDelay={closeDelay}
|
|
74
|
+
/>
|
|
75
|
+
<BasePreviewCard.Portal ref={portalRef}>
|
|
76
|
+
<BasePreviewCard.Positioner
|
|
77
|
+
side={side}
|
|
78
|
+
align={align}
|
|
79
|
+
sideOffset={4}
|
|
80
|
+
className="z-(--z-overlay) outline-none"
|
|
81
|
+
>
|
|
82
|
+
<BasePreviewCard.Popup
|
|
83
|
+
{...props}
|
|
84
|
+
data-hover-card=""
|
|
85
|
+
className={cn(popoverPanel, className)}
|
|
86
|
+
>
|
|
87
|
+
{(title || description) && (
|
|
88
|
+
<div className="flex flex-col gap-1">
|
|
89
|
+
{title && <p className={popoverTitle}>{title}</p>}
|
|
90
|
+
{description && (
|
|
91
|
+
<p className={popoverDescription}>{description}</p>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
{children}
|
|
96
|
+
</BasePreviewCard.Popup>
|
|
97
|
+
</BasePreviewCard.Positioner>
|
|
98
|
+
</BasePreviewCard.Portal>
|
|
99
|
+
</BasePreviewCard.Root>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
4
|
+
|
|
5
|
+
export type IconButtonVariant = "plain" | "floating" | "accent" | "inverse";
|
|
6
|
+
export type IconButtonSize = "sm" | "md" | "lg";
|
|
7
|
+
|
|
8
|
+
export type IconButtonProps = Omit<ComponentProps<"button">, "aria-label"> & {
|
|
9
|
+
/** 纯图标控件必须有可访问名称 */
|
|
10
|
+
"aria-label": string;
|
|
11
|
+
/**
|
|
12
|
+
* - `plain` 方形浅灰底,工具栏与顶栏,默认;
|
|
13
|
+
* - `floating` 圆形白底 + 环境阴影,翻页与浮在图像上的操作;
|
|
14
|
+
* - `accent` 方形,悬停或激活时变信号黄;
|
|
15
|
+
* - `inverse` 方形,悬停或激活时变墨底;它打开的菜单、展开条开着的时候也是。
|
|
16
|
+
*/
|
|
17
|
+
variant?: IconButtonVariant;
|
|
18
|
+
size?: IconButtonSize;
|
|
19
|
+
/** 开关类按钮的激活状态,同时输出 `aria-pressed` */
|
|
20
|
+
pressed?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const sizeClass: Record<IconButtonSize, string> = {
|
|
24
|
+
// 32px 小于触屏的最小点击区,用伪元素向外补到 40px
|
|
25
|
+
sm: "size-8 after:absolute after:-inset-1 after:content-[''] [&_svg]:size-4",
|
|
26
|
+
md: "size-10 [&_svg]:size-5",
|
|
27
|
+
lg: "size-12 [&_svg]:size-6",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const restClass =
|
|
31
|
+
"bg-surface-sunken text-ink-secondary hover:bg-surface-muted hover:text-ink active:bg-line";
|
|
32
|
+
|
|
33
|
+
const enabledClass: Record<IconButtonVariant, string> = {
|
|
34
|
+
plain: restClass,
|
|
35
|
+
// 有意不随主题翻转:它要压在图像和深色版块上
|
|
36
|
+
floating:
|
|
37
|
+
"rounded-full bg-neutral-50 text-neutral-900 shadow-sm hover:bg-neutral-200 active:bg-neutral-300",
|
|
38
|
+
accent:
|
|
39
|
+
"bg-surface-sunken text-ink-secondary hover:bg-action hover:text-on-action active:bg-action-pressed",
|
|
40
|
+
inverse:
|
|
41
|
+
// data-popup-open 是浮层基元写在触发元素上的:它打开的东西还开着
|
|
42
|
+
"bg-surface-sunken text-ink-secondary hover:bg-surface-inverse hover:text-accent-ink-inverse active:bg-surface-inverse data-popup-open:bg-surface-inverse data-popup-open:text-accent-ink-inverse",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const pressedClass: Record<IconButtonVariant, string> = {
|
|
46
|
+
plain: "bg-surface-muted text-ink active:bg-line",
|
|
47
|
+
floating:
|
|
48
|
+
"rounded-full bg-neutral-200 text-neutral-900 shadow-sm active:bg-neutral-300",
|
|
49
|
+
accent: "bg-action text-on-action active:bg-action-pressed",
|
|
50
|
+
inverse: "bg-surface-inverse text-accent-ink-inverse",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const disabledClass: Record<IconButtonVariant, string> = {
|
|
54
|
+
plain: "cursor-not-allowed bg-surface-sunken text-ink-disabled",
|
|
55
|
+
floating:
|
|
56
|
+
"cursor-not-allowed rounded-full bg-neutral-50 text-neutral-400 shadow-sm",
|
|
57
|
+
accent: "cursor-not-allowed bg-surface-sunken text-ink-disabled",
|
|
58
|
+
inverse: "cursor-not-allowed bg-surface-sunken text-ink-disabled",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function IconButton({
|
|
62
|
+
variant = "plain",
|
|
63
|
+
size = "md",
|
|
64
|
+
pressed,
|
|
65
|
+
disabled = false,
|
|
66
|
+
type = "button",
|
|
67
|
+
className,
|
|
68
|
+
children,
|
|
69
|
+
...props
|
|
70
|
+
}: IconButtonProps) {
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
{...props}
|
|
74
|
+
type={type}
|
|
75
|
+
disabled={disabled}
|
|
76
|
+
aria-pressed={pressed}
|
|
77
|
+
data-variant={variant}
|
|
78
|
+
data-size={size}
|
|
79
|
+
className={cn(
|
|
80
|
+
"relative inline-flex shrink-0 items-center justify-center",
|
|
81
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
82
|
+
focusRing,
|
|
83
|
+
sizeClass[size],
|
|
84
|
+
disabled
|
|
85
|
+
? disabledClass[variant]
|
|
86
|
+
: pressed
|
|
87
|
+
? pressedClass[variant]
|
|
88
|
+
: enabledClass[variant],
|
|
89
|
+
className,
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
</button>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
|
|
2
|
+
import {
|
|
3
|
+
Children,
|
|
4
|
+
createContext,
|
|
5
|
+
isValidElement,
|
|
6
|
+
useContext,
|
|
7
|
+
useRef,
|
|
8
|
+
type ComponentProps,
|
|
9
|
+
type KeyboardEvent,
|
|
10
|
+
type ReactElement,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
type RefObject,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { useControllableState } from "../../hooks/useControllableState";
|
|
15
|
+
import { ChevronLeft } from "../../icons/ChevronLeft";
|
|
16
|
+
import { ChevronRight } from "../../icons/ChevronRight";
|
|
17
|
+
import { Close } from "../../icons/Close";
|
|
18
|
+
import { cn } from "../../lib/cn";
|
|
19
|
+
import { focusRing } from "../../lib/focus-ring";
|
|
20
|
+
import { useSnapTrack } from "../carousel/snap-track";
|
|
21
|
+
import { overlayClose, overlayHeader } from "../dialog/overlay-style";
|
|
22
|
+
import { IconButton } from "../icon-button/IconButton";
|
|
23
|
+
import { Viewfinder } from "../viewfinder/Viewfinder";
|
|
24
|
+
|
|
25
|
+
export type ImageViewerRatio = "16/9" | "4/3" | "1/1" | "3/4";
|
|
26
|
+
|
|
27
|
+
const ratioClass: Record<ImageViewerRatio, string> = {
|
|
28
|
+
"16/9": "aspect-video",
|
|
29
|
+
"4/3": "aspect-4/3",
|
|
30
|
+
"1/1": "aspect-square",
|
|
31
|
+
"3/4": "aspect-3/4",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type ItemLike = {
|
|
35
|
+
title?: ReactNode;
|
|
36
|
+
description?: ReactNode;
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const ItemContext = createContext<{
|
|
41
|
+
position: number;
|
|
42
|
+
count: number;
|
|
43
|
+
ratio: ImageViewerRatio;
|
|
44
|
+
viewLabel: string;
|
|
45
|
+
open: (position: number) => void;
|
|
46
|
+
register: (position: number, node: HTMLButtonElement | null) => void;
|
|
47
|
+
} | null>(null);
|
|
48
|
+
|
|
49
|
+
export type ImageViewerProps = Omit<ComponentProps<"ul">, "aria-label"> & {
|
|
50
|
+
/** 这组图是什么,比如"现场照片"。缩略图的列表和大图层都用它命名 */
|
|
51
|
+
"aria-label": string;
|
|
52
|
+
/** 大图层开没开 */
|
|
53
|
+
open?: boolean;
|
|
54
|
+
onOpenChange?: (open: boolean) => void;
|
|
55
|
+
/** 大图层现在是第几张,从 0 起 */
|
|
56
|
+
index?: number;
|
|
57
|
+
defaultIndex?: number;
|
|
58
|
+
onIndexChange?: (index: number) => void;
|
|
59
|
+
/** 到头之后绕回另一头。默认到头时翻页钮禁用 */
|
|
60
|
+
loop?: boolean;
|
|
61
|
+
/** 缩略图的宽高比,默认 `4/3`。大图不裁切,按原比例整张显示 */
|
|
62
|
+
ratio?: ImageViewerRatio;
|
|
63
|
+
/** 缩略图按钮名称的开头,默认"查看大图" */
|
|
64
|
+
viewLabel?: string;
|
|
65
|
+
previousLabel?: string;
|
|
66
|
+
nextLabel?: string;
|
|
67
|
+
closeLabel?: string;
|
|
68
|
+
/** 直接放 `ImageViewerItem` */
|
|
69
|
+
children: ReactNode;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 图片查看:一组缩略图,点一张放大到整屏看,前后翻。
|
|
74
|
+
* 大图那一层在亮暗两个主题下都是深色;翻页用的是媒体轮播的那条轨道,触屏可以左右滑。
|
|
75
|
+
*
|
|
76
|
+
* `className` 和其余属性给缩略图的列表。
|
|
77
|
+
*/
|
|
78
|
+
export function ImageViewer({
|
|
79
|
+
"aria-label": label,
|
|
80
|
+
open: openProp,
|
|
81
|
+
onOpenChange,
|
|
82
|
+
index: indexProp,
|
|
83
|
+
defaultIndex = 0,
|
|
84
|
+
onIndexChange,
|
|
85
|
+
loop = false,
|
|
86
|
+
ratio = "4/3",
|
|
87
|
+
viewLabel = "查看大图",
|
|
88
|
+
previousLabel = "上一张",
|
|
89
|
+
nextLabel = "下一张",
|
|
90
|
+
closeLabel = "关闭",
|
|
91
|
+
className,
|
|
92
|
+
children,
|
|
93
|
+
...props
|
|
94
|
+
}: ImageViewerProps) {
|
|
95
|
+
// 标题、说明和大图都从子元素上读,所以子元素必须直接是 ImageViewerItem
|
|
96
|
+
const items = Children.toArray(children).filter((child) =>
|
|
97
|
+
isValidElement<ItemLike>(child),
|
|
98
|
+
);
|
|
99
|
+
const count = items.length;
|
|
100
|
+
const last = Math.max(0, count - 1);
|
|
101
|
+
|
|
102
|
+
const [open, setOpen] = useControllableState({
|
|
103
|
+
value: openProp,
|
|
104
|
+
defaultValue: false,
|
|
105
|
+
onChange: onOpenChange,
|
|
106
|
+
});
|
|
107
|
+
const [rawIndex, setIndex] = useControllableState({
|
|
108
|
+
value: indexProp,
|
|
109
|
+
defaultValue: defaultIndex,
|
|
110
|
+
onChange: onIndexChange,
|
|
111
|
+
});
|
|
112
|
+
const index = Math.min(Math.max(rawIndex, 0), last);
|
|
113
|
+
const thumbs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
114
|
+
|
|
115
|
+
const openAt = (position: number) => {
|
|
116
|
+
setIndex(position);
|
|
117
|
+
setOpen(true);
|
|
118
|
+
};
|
|
119
|
+
const register = (position: number, node: HTMLButtonElement | null) => {
|
|
120
|
+
thumbs.current[position] = node;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<ul
|
|
126
|
+
{...props}
|
|
127
|
+
aria-label={label}
|
|
128
|
+
data-ratio={ratio}
|
|
129
|
+
className={cn(
|
|
130
|
+
"grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2 text-ink",
|
|
131
|
+
className,
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
{items.map((item, position) => (
|
|
135
|
+
<ItemContext
|
|
136
|
+
key={position}
|
|
137
|
+
value={{
|
|
138
|
+
position,
|
|
139
|
+
count,
|
|
140
|
+
ratio,
|
|
141
|
+
viewLabel,
|
|
142
|
+
open: openAt,
|
|
143
|
+
register,
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{item}
|
|
147
|
+
</ItemContext>
|
|
148
|
+
))}
|
|
149
|
+
</ul>
|
|
150
|
+
<BaseDialog.Root open={open} onOpenChange={(next) => setOpen(next)}>
|
|
151
|
+
<BaseDialog.Portal>
|
|
152
|
+
<BaseDialog.Viewport className="fixed inset-0 z-(--z-overlay)">
|
|
153
|
+
<Stage
|
|
154
|
+
label={label}
|
|
155
|
+
items={items}
|
|
156
|
+
index={index}
|
|
157
|
+
setIndex={setIndex}
|
|
158
|
+
loop={loop}
|
|
159
|
+
previousLabel={previousLabel}
|
|
160
|
+
nextLabel={nextLabel}
|
|
161
|
+
closeLabel={closeLabel}
|
|
162
|
+
thumbs={thumbs}
|
|
163
|
+
onClose={() => setOpen(false)}
|
|
164
|
+
/>
|
|
165
|
+
</BaseDialog.Viewport>
|
|
166
|
+
</BaseDialog.Portal>
|
|
167
|
+
</BaseDialog.Root>
|
|
168
|
+
</>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type StageProps = {
|
|
173
|
+
label: string;
|
|
174
|
+
items: ReactElement<ItemLike>[];
|
|
175
|
+
index: number;
|
|
176
|
+
setIndex: (index: number) => void;
|
|
177
|
+
loop: boolean;
|
|
178
|
+
previousLabel: string;
|
|
179
|
+
nextLabel: string;
|
|
180
|
+
closeLabel: string;
|
|
181
|
+
thumbs: RefObject<(HTMLButtonElement | null)[]>;
|
|
182
|
+
onClose: () => void;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** 大图层。只在打开的时候挂着:轨道一挂上就直接对到被点的那一张 */
|
|
186
|
+
function Stage({
|
|
187
|
+
label,
|
|
188
|
+
items,
|
|
189
|
+
index,
|
|
190
|
+
setIndex,
|
|
191
|
+
loop,
|
|
192
|
+
previousLabel,
|
|
193
|
+
nextLabel,
|
|
194
|
+
closeLabel,
|
|
195
|
+
thumbs,
|
|
196
|
+
onClose,
|
|
197
|
+
}: StageProps) {
|
|
198
|
+
const count = items.length;
|
|
199
|
+
const last = Math.max(0, count - 1);
|
|
200
|
+
const { trackRef, onScroll } = useSnapTrack({
|
|
201
|
+
index,
|
|
202
|
+
count,
|
|
203
|
+
onSettle: setIndex,
|
|
204
|
+
});
|
|
205
|
+
const popupRef = useRef<HTMLDivElement>(null);
|
|
206
|
+
// 关的时候焦点回到哪张缩略图,要读那一刻的下标
|
|
207
|
+
const indexRef = useRef(index);
|
|
208
|
+
indexRef.current = index;
|
|
209
|
+
|
|
210
|
+
const go = (next: number) => {
|
|
211
|
+
if (count === 0) return;
|
|
212
|
+
const target = loop
|
|
213
|
+
? (next + count) % count
|
|
214
|
+
: Math.min(Math.max(next, 0), last);
|
|
215
|
+
setIndex(target);
|
|
216
|
+
// 到头了,刚按的那个翻页钮马上要禁用:把焦点交还给这一层,键盘还能接着用方向键翻回去
|
|
217
|
+
if (
|
|
218
|
+
!loop &&
|
|
219
|
+
(target === 0 || target === last) &&
|
|
220
|
+
document.activeElement instanceof HTMLButtonElement &&
|
|
221
|
+
document.activeElement.hasAttribute("data-image-viewer-nav")
|
|
222
|
+
) {
|
|
223
|
+
popupRef.current?.focus({ preventScroll: true });
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// 焦点在这一层的哪儿都能翻;视频自己的控件要用方向键,不抢
|
|
228
|
+
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
229
|
+
if (event.defaultPrevented) return;
|
|
230
|
+
if ((event.target as Element).closest("video, audio, input, textarea")) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const next = {
|
|
234
|
+
ArrowLeft: index - 1,
|
|
235
|
+
ArrowRight: index + 1,
|
|
236
|
+
Home: 0,
|
|
237
|
+
End: last,
|
|
238
|
+
}[event.key];
|
|
239
|
+
if (next === undefined) return;
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
go(next);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const current = items[index]?.props;
|
|
245
|
+
const many = count > 1;
|
|
246
|
+
const captioned = Boolean(current?.title || current?.description);
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<BaseDialog.Popup
|
|
250
|
+
// 两个主题下都是深色:里面的字、关闭钮、焦点环都按深色取值
|
|
251
|
+
data-theme="dark"
|
|
252
|
+
data-image-viewer=""
|
|
253
|
+
ref={popupRef}
|
|
254
|
+
// 打开时焦点落在这一层自己身上:方向键马上能用,Tab 再走到关闭钮和翻页钮。
|
|
255
|
+
// 它不是一个能操作的东西,所以没有焦点环——不然整个舞台外面会套一圈
|
|
256
|
+
initialFocus={popupRef}
|
|
257
|
+
finalFocus={() => thumbs.current[indexRef.current] ?? true}
|
|
258
|
+
onKeyDown={onKeyDown}
|
|
259
|
+
className={cn(
|
|
260
|
+
"flex size-full flex-col bg-surface text-ink outline-none",
|
|
261
|
+
"transition-opacity duration-(--duration-fast) ease-standard",
|
|
262
|
+
"data-ending-style:opacity-0 data-starting-style:opacity-0",
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
<div className={cn(overlayHeader, "h-14 px-4")}>
|
|
266
|
+
<BaseDialog.Title className="sr-only">{label}</BaseDialog.Title>
|
|
267
|
+
{many && (
|
|
268
|
+
// 看得见的计数只给眼睛:同样的话下面的说明区会播报
|
|
269
|
+
<span
|
|
270
|
+
aria-hidden="true"
|
|
271
|
+
data-count=""
|
|
272
|
+
className="font-tech text-sm text-ink-secondary tabular-nums"
|
|
273
|
+
>
|
|
274
|
+
{`${index + 1} / ${count}`}
|
|
275
|
+
</span>
|
|
276
|
+
)}
|
|
277
|
+
<BaseDialog.Close aria-label={closeLabel} className={overlayClose}>
|
|
278
|
+
<Close size={24} />
|
|
279
|
+
</BaseDialog.Close>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<Viewfinder size="md" className="min-h-0 flex-1">
|
|
283
|
+
<div
|
|
284
|
+
ref={trackRef}
|
|
285
|
+
data-image-viewer-track=""
|
|
286
|
+
onScroll={onScroll}
|
|
287
|
+
className="absolute inset-0 flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain [scrollbar-width:none]"
|
|
288
|
+
>
|
|
289
|
+
{items.map((item, position) => (
|
|
290
|
+
<div
|
|
291
|
+
key={position}
|
|
292
|
+
role="group"
|
|
293
|
+
aria-roledescription="幻灯片"
|
|
294
|
+
aria-label={`第 ${position + 1} 张,共 ${count} 张`}
|
|
295
|
+
// 不在眼前的:Tab 走不进去,读屏也读不到
|
|
296
|
+
inert={position !== index}
|
|
297
|
+
data-current={position === index ? "" : undefined}
|
|
298
|
+
// 点图以外的空处就是关
|
|
299
|
+
onClick={(event) => {
|
|
300
|
+
if (event.target === event.currentTarget) onClose();
|
|
301
|
+
}}
|
|
302
|
+
className={cn(
|
|
303
|
+
"flex size-full shrink-0 snap-start items-center justify-center p-6 sm:p-10",
|
|
304
|
+
// 图按原比例整张放进去:不裁切,也不放大过它自己的尺寸
|
|
305
|
+
"*:max-h-full *:max-w-full [&>img]:object-contain [&>video]:object-contain",
|
|
306
|
+
)}
|
|
307
|
+
>
|
|
308
|
+
{/* 只渲染当前这张和左右各一张:二十张图不会一打开就全去取 */}
|
|
309
|
+
{Math.abs(position - index) <= 1 ? item.props.children : null}
|
|
310
|
+
</div>
|
|
311
|
+
))}
|
|
312
|
+
</div>
|
|
313
|
+
</Viewfinder>
|
|
314
|
+
|
|
315
|
+
{(many || captioned) && (
|
|
316
|
+
<div className="flex shrink-0 flex-wrap items-start gap-x-6 gap-y-3 border-t border-line px-4 py-4">
|
|
317
|
+
{many && (
|
|
318
|
+
<div className="flex shrink-0 gap-2">
|
|
319
|
+
<IconButton
|
|
320
|
+
variant="floating"
|
|
321
|
+
aria-label={previousLabel}
|
|
322
|
+
data-image-viewer-nav=""
|
|
323
|
+
disabled={!loop && index <= 0}
|
|
324
|
+
onClick={() => go(index - 1)}
|
|
325
|
+
>
|
|
326
|
+
<ChevronLeft />
|
|
327
|
+
</IconButton>
|
|
328
|
+
<IconButton
|
|
329
|
+
variant="floating"
|
|
330
|
+
aria-label={nextLabel}
|
|
331
|
+
data-image-viewer-nav=""
|
|
332
|
+
disabled={!loop && index >= last}
|
|
333
|
+
onClick={() => go(index + 1)}
|
|
334
|
+
>
|
|
335
|
+
<ChevronRight />
|
|
336
|
+
</IconButton>
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
<div
|
|
340
|
+
aria-live="polite"
|
|
341
|
+
className="flex min-w-0 flex-1 basis-48 flex-col gap-1"
|
|
342
|
+
>
|
|
343
|
+
{many && (
|
|
344
|
+
<span className="sr-only">{`第 ${index + 1} 张,共 ${count} 张`}</span>
|
|
345
|
+
)}
|
|
346
|
+
{current?.title && (
|
|
347
|
+
<p className="text-base font-bold wrap-anywhere">
|
|
348
|
+
{current.title}
|
|
349
|
+
</p>
|
|
350
|
+
)}
|
|
351
|
+
{current?.description && (
|
|
352
|
+
<p className="max-h-24 overflow-y-auto text-sm wrap-anywhere text-ink-secondary">
|
|
353
|
+
{current.description}
|
|
354
|
+
</p>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
</BaseDialog.Popup>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export type ImageViewerItemProps = Omit<
|
|
364
|
+
ComponentProps<"li">,
|
|
365
|
+
"title" | "aria-label"
|
|
366
|
+
> & {
|
|
367
|
+
/** 这一张的标题,显示在大图下面;也用来给缩略图按钮起名 */
|
|
368
|
+
title?: ReactNode;
|
|
369
|
+
/** 标题下面的一段说明 */
|
|
370
|
+
description?: ReactNode;
|
|
371
|
+
/** 另给的小图,放在缩略图里。不传就用同一张图 */
|
|
372
|
+
thumbnail?: ReactNode;
|
|
373
|
+
/** 缩略图按钮的可访问名称。默认是"查看大图:标题",没有文字标题时报第几张 */
|
|
374
|
+
"aria-label"?: string;
|
|
375
|
+
/** 图:一个 `<img>`(或 `<video>`)。大图层里按原比例整张显示 */
|
|
376
|
+
children: ReactNode;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** 图片查看里的一张。直接放在 `ImageViewer` 里。 */
|
|
380
|
+
export function ImageViewerItem({
|
|
381
|
+
title,
|
|
382
|
+
// 说明由 ImageViewer 读走,画在大图下面
|
|
383
|
+
description: _description,
|
|
384
|
+
thumbnail,
|
|
385
|
+
"aria-label": ariaLabel,
|
|
386
|
+
className,
|
|
387
|
+
children,
|
|
388
|
+
...props
|
|
389
|
+
}: ImageViewerItemProps) {
|
|
390
|
+
const item = useContext(ItemContext);
|
|
391
|
+
if (!item) {
|
|
392
|
+
throw new Error("ImageViewerItem 要直接放在 ImageViewer 里面");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const name =
|
|
396
|
+
ariaLabel ??
|
|
397
|
+
`${item.viewLabel}:${
|
|
398
|
+
typeof title === "string"
|
|
399
|
+
? title
|
|
400
|
+
: `第 ${item.position + 1} 张,共 ${item.count} 张`
|
|
401
|
+
}`;
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
<li {...props} className={cn("min-w-0", className)}>
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
ref={(node) => item.register(item.position, node)}
|
|
408
|
+
aria-label={name}
|
|
409
|
+
aria-haspopup="dialog"
|
|
410
|
+
onClick={() => item.open(item.position)}
|
|
411
|
+
className={cn(
|
|
412
|
+
"group/thumb relative block w-full overflow-clip rounded-md bg-surface-muted",
|
|
413
|
+
"[&>img]:size-full [&>img]:object-cover [&>svg]:size-full [&>video]:size-full [&>video]:object-cover",
|
|
414
|
+
focusRing,
|
|
415
|
+
ratioClass[item.ratio],
|
|
416
|
+
)}
|
|
417
|
+
>
|
|
418
|
+
{thumbnail ?? children}
|
|
419
|
+
{/* 悬停:一条底边强调线从左充出,同媒体卡。不放大、不提亮 */}
|
|
420
|
+
<span
|
|
421
|
+
aria-hidden="true"
|
|
422
|
+
className="pointer-events-none absolute inset-x-0 bottom-0 h-1 origin-left scale-x-0 bg-action transition-transform duration-(--duration-base) ease-standard group-hover/thumb:scale-x-100 group-focus-visible/thumb:scale-x-100"
|
|
423
|
+
/>
|
|
424
|
+
</button>
|
|
425
|
+
</li>
|
|
426
|
+
);
|
|
427
|
+
}
|