@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,425 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
type ChangeEvent,
|
|
6
|
+
type DragEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type Ref,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { useControllableState } from "../../hooks/useControllableState";
|
|
11
|
+
import { StatusDanger } from "../../icons/StatusDanger";
|
|
12
|
+
import { Upload } from "../../icons/Upload";
|
|
13
|
+
import { cn } from "../../lib/cn";
|
|
14
|
+
import { decor } from "../../lib/decor";
|
|
15
|
+
import { formatFileSize, matchesAccept, sameFile } from "../../lib/file";
|
|
16
|
+
import { focusRingWithin } from "../../lib/focus-ring";
|
|
17
|
+
import { mergeRefs } from "../../lib/merge-refs";
|
|
18
|
+
import { useFieldControl } from "../field/Field";
|
|
19
|
+
import { FileItem } from "./FileItem";
|
|
20
|
+
|
|
21
|
+
export type FileUploadSize = "sm" | "md";
|
|
22
|
+
|
|
23
|
+
/** 没收的原因:类型不对、太大、个数超了 */
|
|
24
|
+
export type FileRejectReason = "type" | "size" | "count";
|
|
25
|
+
export type FileRejection = { file: File; reason: FileRejectReason };
|
|
26
|
+
|
|
27
|
+
/** 文件列表的名称、没收时的那几句话 */
|
|
28
|
+
export type FileUploadLabels = {
|
|
29
|
+
/** 文件列表的可访问名称 */
|
|
30
|
+
list: string;
|
|
31
|
+
/** 选了几个:读屏的播报 */
|
|
32
|
+
selected: (count: number) => string;
|
|
33
|
+
/** 没收了几个:那一行说明的开头 */
|
|
34
|
+
rejected: (count: number) => string;
|
|
35
|
+
type: string;
|
|
36
|
+
size: (limit: string) => string;
|
|
37
|
+
count: (max: number) => string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const defaultLabels: FileUploadLabels = {
|
|
41
|
+
list: "已选的文件",
|
|
42
|
+
selected: (count) => (count === 0 ? "没有文件" : `已选 ${count} 个文件`),
|
|
43
|
+
rejected: (count) => `${count} 个文件没有加进来`,
|
|
44
|
+
type: "类型不对",
|
|
45
|
+
size: (limit) => `超过 ${limit}`,
|
|
46
|
+
count: (max) => `超出个数上限(最多 ${max} 个)`,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type FileUploadProps = {
|
|
50
|
+
/** 受控的值 */
|
|
51
|
+
value?: File[];
|
|
52
|
+
/** 非受控时的初始值 */
|
|
53
|
+
defaultValue?: File[];
|
|
54
|
+
onValueChange?: (files: File[]) => void;
|
|
55
|
+
/** 可以选几个。不开时新选的替掉原来的 */
|
|
56
|
+
multiple?: boolean;
|
|
57
|
+
/** 收哪些类型,写法和原生的一样:`.pdf,image/*` */
|
|
58
|
+
accept?: string;
|
|
59
|
+
/** 单个文件最大多少字节 */
|
|
60
|
+
maxSize?: number;
|
|
61
|
+
/** 最多几个(`multiple` 时) */
|
|
62
|
+
maxFiles?: number;
|
|
63
|
+
/** 有文件没收:拿到每个文件和原因 */
|
|
64
|
+
onReject?: (rejections: FileRejection[]) => void;
|
|
65
|
+
/** 表单字段名:列表里的文件随表单提交 */
|
|
66
|
+
name?: string;
|
|
67
|
+
/** 投放区的第一行字,后面跟着 `browseLabel` */
|
|
68
|
+
title?: ReactNode;
|
|
69
|
+
/** 带下划线的那几个字 */
|
|
70
|
+
browseLabel?: ReactNode;
|
|
71
|
+
/** 文件拖到上面时换成的字 */
|
|
72
|
+
dropLabel?: ReactNode;
|
|
73
|
+
/** 第二行:把限制写清楚——"PDF 或图片,单个不超过 10 MB" */
|
|
74
|
+
description?: ReactNode;
|
|
75
|
+
/** `md` 两行,默认;`sm` 是 40px 高的一行 */
|
|
76
|
+
size?: FileUploadSize;
|
|
77
|
+
/** 错误态。放在 `Field` 里时跟随它的 `error` */
|
|
78
|
+
invalid?: boolean;
|
|
79
|
+
disabled?: boolean;
|
|
80
|
+
required?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* 自己画每个文件的那一行:选了就要传、要显示进度时用,返回一个带 `status` 的 `FileItem`。
|
|
83
|
+
* 不传就是一行不带状态的 `FileItem`
|
|
84
|
+
*/
|
|
85
|
+
renderFile?: (
|
|
86
|
+
file: File,
|
|
87
|
+
actions: { remove: () => void; index: number },
|
|
88
|
+
) => ReactNode;
|
|
89
|
+
/** 移除钮的可访问名称,拿到的是文件名。默认"移除某某" */
|
|
90
|
+
removeLabel?: (name: string) => string;
|
|
91
|
+
/** 文件列表的名称、没收时的那几句话,换哪句传哪句 */
|
|
92
|
+
labels?: Partial<FileUploadLabels>;
|
|
93
|
+
id?: string;
|
|
94
|
+
"aria-label"?: string;
|
|
95
|
+
"aria-describedby"?: string;
|
|
96
|
+
/** 给最外面那一层 */
|
|
97
|
+
className?: string;
|
|
98
|
+
/** 给里面的 `<input type="file">` */
|
|
99
|
+
ref?: Ref<HTMLInputElement>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const zoneSize: Record<FileUploadSize, string> = {
|
|
103
|
+
sm: "min-h-10 flex-wrap gap-x-3 px-3 py-2 text-sm",
|
|
104
|
+
md: "gap-3 px-4 py-4",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** 拖进来的东西里的文件;文件夹不收 */
|
|
108
|
+
function droppedFiles(transfer: DataTransfer): File[] {
|
|
109
|
+
const items = [...(transfer.items ?? [])];
|
|
110
|
+
if (items.length === 0) return [...transfer.files];
|
|
111
|
+
return items.flatMap((item) => {
|
|
112
|
+
if (item.kind !== "file") return [];
|
|
113
|
+
if (item.webkitGetAsEntry?.()?.isDirectory) return [];
|
|
114
|
+
return item.getAsFile() ?? [];
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 文件上传:选文件,或者把文件拖进来。它只管"选"和"列"——发请求、算进度是使用方的事。
|
|
120
|
+
*
|
|
121
|
+
* 里面是一个真的 `<input type="file">`,铺满整个投放区:点哪儿都是打开系统的文件框。
|
|
122
|
+
* 每个文件上传都要有标签:放进 `Field`,或者自己传 `aria-label`。
|
|
123
|
+
*/
|
|
124
|
+
export function FileUpload({
|
|
125
|
+
value,
|
|
126
|
+
defaultValue,
|
|
127
|
+
onValueChange,
|
|
128
|
+
multiple = false,
|
|
129
|
+
accept,
|
|
130
|
+
maxSize,
|
|
131
|
+
maxFiles,
|
|
132
|
+
onReject,
|
|
133
|
+
name,
|
|
134
|
+
title = "把文件拖到这里,或者",
|
|
135
|
+
browseLabel = "选择文件",
|
|
136
|
+
dropLabel = "松开,放进来",
|
|
137
|
+
description,
|
|
138
|
+
size = "md",
|
|
139
|
+
invalid: invalidProp,
|
|
140
|
+
disabled: disabledProp,
|
|
141
|
+
required: requiredProp,
|
|
142
|
+
renderFile,
|
|
143
|
+
removeLabel = (fileName) => `移除${fileName}`,
|
|
144
|
+
labels: labelsProp,
|
|
145
|
+
id: idProp,
|
|
146
|
+
"aria-label": ariaLabel,
|
|
147
|
+
"aria-describedby": describedByProp,
|
|
148
|
+
className,
|
|
149
|
+
ref,
|
|
150
|
+
}: FileUploadProps) {
|
|
151
|
+
const { id, disabled, required, invalid, ...field } = useFieldControl({
|
|
152
|
+
id: idProp,
|
|
153
|
+
disabled: disabledProp,
|
|
154
|
+
required: requiredProp,
|
|
155
|
+
invalid: invalidProp,
|
|
156
|
+
"aria-describedby": describedByProp,
|
|
157
|
+
});
|
|
158
|
+
const labels = { ...defaultLabels, ...labelsProp };
|
|
159
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
160
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
161
|
+
|
|
162
|
+
const [files, setFiles] = useControllableState<File[]>({
|
|
163
|
+
value,
|
|
164
|
+
defaultValue: defaultValue ?? [],
|
|
165
|
+
onChange: onValueChange,
|
|
166
|
+
});
|
|
167
|
+
const [rejections, setRejections] = useState<FileRejection[]>([]);
|
|
168
|
+
const [dragging, setDragging] = useState(false);
|
|
169
|
+
// dragenter / dragleave 在每个子元素上都会来一次:数着,归零才算真的离开
|
|
170
|
+
const depth = useRef(0);
|
|
171
|
+
// 每加一次、删一次就把列表写回原生输入框一次(值没变也要写:它里面可能是刚被没收的那几个)
|
|
172
|
+
const [revision, setRevision] = useState(0);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
const input = inputRef.current;
|
|
176
|
+
if (!input || typeof DataTransfer === "undefined") return;
|
|
177
|
+
try {
|
|
178
|
+
const transfer = new DataTransfer();
|
|
179
|
+
for (const file of files) transfer.items.add(file);
|
|
180
|
+
input.files = transfer.files;
|
|
181
|
+
} catch {
|
|
182
|
+
// 写不回去的环境里(很老的浏览器)表单提交的是最后一次选的,列表照常显示
|
|
183
|
+
}
|
|
184
|
+
}, [files, revision]);
|
|
185
|
+
|
|
186
|
+
// 移除之后焦点落到哪:等这一次渲染完
|
|
187
|
+
const pendingFocus = useRef<number | null>(null);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const index = pendingFocus.current;
|
|
190
|
+
if (index === null) return;
|
|
191
|
+
pendingFocus.current = null;
|
|
192
|
+
const buttons =
|
|
193
|
+
listRef.current?.querySelectorAll<HTMLElement>("[data-file-remove]") ??
|
|
194
|
+
[];
|
|
195
|
+
const target = buttons[Math.min(index, buttons.length - 1)];
|
|
196
|
+
if (target) target.focus();
|
|
197
|
+
else inputRef.current?.focus();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const add = (incoming: readonly File[]) => {
|
|
201
|
+
const rejected: FileRejection[] = [];
|
|
202
|
+
let next = multiple ? [...files] : [];
|
|
203
|
+
for (const file of incoming) {
|
|
204
|
+
if (!matchesAccept(file, accept)) {
|
|
205
|
+
rejected.push({ file, reason: "type" });
|
|
206
|
+
} else if (maxSize !== undefined && file.size > maxSize) {
|
|
207
|
+
rejected.push({ file, reason: "size" });
|
|
208
|
+
} else if (!multiple) {
|
|
209
|
+
// 只收一个:第一个合格的替掉原来的,再多的算超了
|
|
210
|
+
if (next.length === 0) next = [file];
|
|
211
|
+
else rejected.push({ file, reason: "count" });
|
|
212
|
+
} else if (next.some((existing) => sameFile(existing, file))) {
|
|
213
|
+
// 同一个文件不重复加,也不算错
|
|
214
|
+
} else if (maxFiles !== undefined && next.length >= maxFiles) {
|
|
215
|
+
rejected.push({ file, reason: "count" });
|
|
216
|
+
} else {
|
|
217
|
+
next.push(file);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// 只收一个、这次又一个合格的都没有:原来的留着
|
|
221
|
+
if (!multiple && next.length === 0) next = files;
|
|
222
|
+
|
|
223
|
+
const changed =
|
|
224
|
+
next.length !== files.length ||
|
|
225
|
+
next.some((file, index) => file !== files[index]);
|
|
226
|
+
if (changed) setFiles(next);
|
|
227
|
+
setRejections(rejected);
|
|
228
|
+
setRevision((current) => current + 1);
|
|
229
|
+
if (rejected.length > 0) onReject?.(rejected);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const remove = (index: number) => {
|
|
233
|
+
pendingFocus.current = index;
|
|
234
|
+
setFiles(files.filter((_, position) => position !== index));
|
|
235
|
+
setRejections([]);
|
|
236
|
+
setRevision((current) => current + 1);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
240
|
+
add([...(event.target.files ?? [])]);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const hasFiles = (event: DragEvent) =>
|
|
244
|
+
[...event.dataTransfer.types].includes("Files");
|
|
245
|
+
|
|
246
|
+
const handleDragEnter = (event: DragEvent) => {
|
|
247
|
+
if (disabled || !hasFiles(event)) return;
|
|
248
|
+
depth.current += 1;
|
|
249
|
+
setDragging(true);
|
|
250
|
+
};
|
|
251
|
+
const handleDragLeave = (event: DragEvent) => {
|
|
252
|
+
if (disabled || !hasFiles(event)) return;
|
|
253
|
+
depth.current = Math.max(depth.current - 1, 0);
|
|
254
|
+
if (depth.current === 0) setDragging(false);
|
|
255
|
+
};
|
|
256
|
+
const handleDragOver = (event: DragEvent) => {
|
|
257
|
+
// 不拦下来浏览器不让放;禁用时也拦,免得整页跳去打开这个文件
|
|
258
|
+
if (hasFiles(event)) event.preventDefault();
|
|
259
|
+
};
|
|
260
|
+
const handleDrop = (event: DragEvent) => {
|
|
261
|
+
if (!hasFiles(event)) return;
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
depth.current = 0;
|
|
264
|
+
setDragging(false);
|
|
265
|
+
if (!disabled) add(droppedFiles(event.dataTransfer));
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const reasonText = ({ reason }: FileRejection) => {
|
|
269
|
+
if (reason === "type") return labels.type;
|
|
270
|
+
if (reason === "size") return labels.size(formatFileSize(maxSize ?? 0));
|
|
271
|
+
return labels.count(multiple ? (maxFiles ?? 0) : 1);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div
|
|
276
|
+
data-size={size}
|
|
277
|
+
data-invalid={invalid ? "" : undefined}
|
|
278
|
+
className={cn("flex min-w-0 flex-col gap-2 text-ink", className)}
|
|
279
|
+
>
|
|
280
|
+
<div
|
|
281
|
+
data-dropzone=""
|
|
282
|
+
data-dragging={dragging ? "" : undefined}
|
|
283
|
+
onDragEnter={handleDragEnter}
|
|
284
|
+
onDragLeave={handleDragLeave}
|
|
285
|
+
onDragOver={handleDragOver}
|
|
286
|
+
onDrop={handleDrop}
|
|
287
|
+
className={cn(
|
|
288
|
+
"relative flex items-center border bg-surface-sunken",
|
|
289
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
290
|
+
focusRingWithin,
|
|
291
|
+
zoneSize[size],
|
|
292
|
+
dragging
|
|
293
|
+
? // 文件拖到上面:线变成实线
|
|
294
|
+
"border-solid border-ink"
|
|
295
|
+
: [
|
|
296
|
+
"border-dashed",
|
|
297
|
+
disabled
|
|
298
|
+
? "cursor-not-allowed border-line text-ink-disabled"
|
|
299
|
+
: invalid
|
|
300
|
+
? "border-danger"
|
|
301
|
+
: "border-line-strong hover:border-ink-secondary",
|
|
302
|
+
],
|
|
303
|
+
)}
|
|
304
|
+
>
|
|
305
|
+
<Upload
|
|
306
|
+
size={size === "sm" ? 20 : 24}
|
|
307
|
+
className={cn(
|
|
308
|
+
"shrink-0",
|
|
309
|
+
disabled ? "text-ink-disabled" : "text-ink-secondary",
|
|
310
|
+
)}
|
|
311
|
+
/>
|
|
312
|
+
<span
|
|
313
|
+
className={cn(
|
|
314
|
+
"flex min-w-0",
|
|
315
|
+
size === "sm"
|
|
316
|
+
? "flex-1 flex-wrap items-baseline gap-x-3"
|
|
317
|
+
: "flex-col gap-0.5",
|
|
318
|
+
)}
|
|
319
|
+
>
|
|
320
|
+
<span className="font-medium">
|
|
321
|
+
{dragging ? (
|
|
322
|
+
dropLabel
|
|
323
|
+
) : (
|
|
324
|
+
<>
|
|
325
|
+
{title}
|
|
326
|
+
<span className="ml-1 underline underline-offset-4">
|
|
327
|
+
{browseLabel}
|
|
328
|
+
</span>
|
|
329
|
+
</>
|
|
330
|
+
)}
|
|
331
|
+
</span>
|
|
332
|
+
{description && (
|
|
333
|
+
<span
|
|
334
|
+
className={cn(
|
|
335
|
+
"text-sm",
|
|
336
|
+
disabled ? "text-ink-disabled" : "text-ink-secondary",
|
|
337
|
+
)}
|
|
338
|
+
>
|
|
339
|
+
{description}
|
|
340
|
+
</span>
|
|
341
|
+
)}
|
|
342
|
+
</span>
|
|
343
|
+
|
|
344
|
+
{/* 拖到上面时的取景角:正在瞄准 */}
|
|
345
|
+
{dragging && (
|
|
346
|
+
<span
|
|
347
|
+
aria-hidden="true"
|
|
348
|
+
data-brackets=""
|
|
349
|
+
className={cn(
|
|
350
|
+
decor,
|
|
351
|
+
"corner-brackets absolute inset-1.5 [--bracket-offset:0px]",
|
|
352
|
+
)}
|
|
353
|
+
/>
|
|
354
|
+
)}
|
|
355
|
+
|
|
356
|
+
{/* 真的文件输入框,看不见,铺满投放区:点哪儿都是它 */}
|
|
357
|
+
<input
|
|
358
|
+
ref={mergeRefs(inputRef, ref)}
|
|
359
|
+
id={id}
|
|
360
|
+
type="file"
|
|
361
|
+
name={name}
|
|
362
|
+
multiple={multiple}
|
|
363
|
+
accept={accept}
|
|
364
|
+
disabled={disabled}
|
|
365
|
+
required={required}
|
|
366
|
+
aria-label={ariaLabel}
|
|
367
|
+
aria-invalid={invalid || undefined}
|
|
368
|
+
aria-describedby={field["aria-describedby"]}
|
|
369
|
+
// 不要浏览器自带的那句"未选择任何文件"的提示
|
|
370
|
+
title=""
|
|
371
|
+
onChange={handleChange}
|
|
372
|
+
// 禁用时让开:拖放的事件要落到投放区上,由它拦下
|
|
373
|
+
className="absolute inset-0 size-full cursor-[inherit] opacity-0 outline-none disabled:pointer-events-none"
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
{/* 这一行是播报给读屏的状态区,元素本身要一直在 */}
|
|
378
|
+
<div aria-live="polite" data-rejections="">
|
|
379
|
+
{rejections.length > 0 && (
|
|
380
|
+
<p className="flex items-start gap-1.5 text-sm text-danger">
|
|
381
|
+
<StatusDanger size={16} className="mt-0.5 shrink-0" />
|
|
382
|
+
<span className="min-w-0 wrap-anywhere">
|
|
383
|
+
{`${labels.rejected(rejections.length)}:${rejections
|
|
384
|
+
.map(
|
|
385
|
+
(rejection) =>
|
|
386
|
+
`${rejection.file.name} ${reasonText(rejection)}`,
|
|
387
|
+
)
|
|
388
|
+
.join(";")}`}
|
|
389
|
+
</span>
|
|
390
|
+
</p>
|
|
391
|
+
)}
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
{files.length > 0 && (
|
|
395
|
+
<ul
|
|
396
|
+
ref={listRef}
|
|
397
|
+
aria-label={labels.list}
|
|
398
|
+
className="flex flex-col gap-1"
|
|
399
|
+
>
|
|
400
|
+
{files.map((file, index) => {
|
|
401
|
+
const actions = { remove: () => remove(index), index };
|
|
402
|
+
return (
|
|
403
|
+
<li key={`${file.name}-${file.size}-${file.lastModified}`}>
|
|
404
|
+
{renderFile ? (
|
|
405
|
+
renderFile(file, actions)
|
|
406
|
+
) : (
|
|
407
|
+
<FileItem
|
|
408
|
+
name={file.name}
|
|
409
|
+
size={file.size}
|
|
410
|
+
disabled={disabled}
|
|
411
|
+
removeLabel={removeLabel(file.name)}
|
|
412
|
+
onRemove={actions.remove}
|
|
413
|
+
/>
|
|
414
|
+
)}
|
|
415
|
+
</li>
|
|
416
|
+
);
|
|
417
|
+
})}
|
|
418
|
+
</ul>
|
|
419
|
+
)}
|
|
420
|
+
<span aria-live="polite" className="sr-only">
|
|
421
|
+
{labels.selected(files.length)}
|
|
422
|
+
</span>
|
|
423
|
+
</div>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Menu as BaseMenu } from "@base-ui/react/menu";
|
|
2
|
+
import type {
|
|
3
|
+
ComponentProps,
|
|
4
|
+
MouseEvent,
|
|
5
|
+
ReactElement,
|
|
6
|
+
ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { usePortalScope } from "../../hooks/usePortalScope";
|
|
9
|
+
import { cn } from "../../lib/cn";
|
|
10
|
+
import { defined } from "../../lib/defined";
|
|
11
|
+
import { focusRingInset } from "../../lib/focus-ring";
|
|
12
|
+
import type { LinkRender } from "../link-element/LinkElement";
|
|
13
|
+
|
|
14
|
+
export type FlyoutBarSide = "right" | "left";
|
|
15
|
+
|
|
16
|
+
export type FlyoutBarProps = {
|
|
17
|
+
/**
|
|
18
|
+
* 触发按钮,通常是 `<IconButton variant="inverse">`。
|
|
19
|
+
* 它的名称(`aria-label`)同时是这一条的名称,读屏打开时会读出来
|
|
20
|
+
*/
|
|
21
|
+
trigger: ReactElement<Record<string, unknown>>;
|
|
22
|
+
/** 展开在触发按钮的哪一侧,放不下时自动翻到另一侧。默认 `right` */
|
|
23
|
+
side?: FlyoutBarSide;
|
|
24
|
+
open?: boolean;
|
|
25
|
+
defaultOpen?: boolean;
|
|
26
|
+
onOpenChange?: (open: boolean) => void;
|
|
27
|
+
/** 给横条本身 */
|
|
28
|
+
className?: string;
|
|
29
|
+
/** `FlyoutBarItem` */
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 展开条:从一个图标按钮旁边拉开的一条横排操作,比如分享的几个去处。
|
|
35
|
+
* 语义和键盘都是横向的菜单——左右方向键移动、回车选择、`Esc` 关闭。
|
|
36
|
+
* 选项多于六个、或者要文字才看得懂时,用 `DropdownMenu`。
|
|
37
|
+
*/
|
|
38
|
+
export function FlyoutBar({
|
|
39
|
+
trigger,
|
|
40
|
+
side = "right",
|
|
41
|
+
open,
|
|
42
|
+
defaultOpen,
|
|
43
|
+
onOpenChange,
|
|
44
|
+
className,
|
|
45
|
+
children,
|
|
46
|
+
}: FlyoutBarProps) {
|
|
47
|
+
const { anchorRef, portalRef } = usePortalScope();
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<BaseMenu.Root
|
|
51
|
+
orientation="horizontal"
|
|
52
|
+
open={open}
|
|
53
|
+
defaultOpen={defaultOpen}
|
|
54
|
+
onOpenChange={onOpenChange && ((next) => onOpenChange(next))}
|
|
55
|
+
>
|
|
56
|
+
<BaseMenu.Trigger ref={anchorRef} render={trigger} />
|
|
57
|
+
<BaseMenu.Portal ref={portalRef}>
|
|
58
|
+
<BaseMenu.Positioner
|
|
59
|
+
side={side}
|
|
60
|
+
align="center"
|
|
61
|
+
className="z-(--z-overlay) outline-none"
|
|
62
|
+
>
|
|
63
|
+
<BaseMenu.Popup
|
|
64
|
+
// 固定的深色:里面的图标和焦点环都按深色底取值
|
|
65
|
+
data-theme="dark"
|
|
66
|
+
className={cn(
|
|
67
|
+
"flex h-(--anchor-height) min-h-10 items-stretch border border-line bg-surface-raised text-ink-secondary shadow-sm outline-none",
|
|
68
|
+
// 黄色竖条始终贴着触发按钮:翻到另一侧时跟着换边
|
|
69
|
+
"data-[side=left]:border-r-4 data-[side=left]:border-r-action data-[side=right]:border-l-4 data-[side=right]:border-l-action",
|
|
70
|
+
// 从触发按钮一侧拉开。用裁切而不是缩放——缩放会把图标压扁。
|
|
71
|
+
// 负的 inset 给阴影留出位置
|
|
72
|
+
"[clip-path:inset(-12px)] transition-[clip-path] duration-(--duration-base) ease-exit",
|
|
73
|
+
"data-[side=right]:data-ending-style:[clip-path:inset(-12px_100%_-12px_-12px)] data-[side=right]:data-starting-style:[clip-path:inset(-12px_100%_-12px_-12px)]",
|
|
74
|
+
"data-[side=left]:data-ending-style:[clip-path:inset(-12px_-12px_-12px_100%)] data-[side=left]:data-starting-style:[clip-path:inset(-12px_-12px_-12px_100%)]",
|
|
75
|
+
className,
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</BaseMenu.Popup>
|
|
80
|
+
</BaseMenu.Positioner>
|
|
81
|
+
</BaseMenu.Portal>
|
|
82
|
+
</BaseMenu.Root>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type ItemOwnProps = {
|
|
87
|
+
/** 只有图标时必须给:它是这一项的名称 */
|
|
88
|
+
"aria-label"?: string;
|
|
89
|
+
disabled?: boolean;
|
|
90
|
+
/** 点了之后是否关掉。操作项默认关,链接项默认不关 */
|
|
91
|
+
closeOnClick?: boolean;
|
|
92
|
+
/** 传了就渲染成链接 */
|
|
93
|
+
href?: string;
|
|
94
|
+
target?: string;
|
|
95
|
+
rel?: string;
|
|
96
|
+
/** 用这个元素代替 `<a>`(路由库的链接组件)。传了就按链接处理 */
|
|
97
|
+
render?: LinkRender;
|
|
98
|
+
onClick?: (event: MouseEvent<HTMLElement>) => void;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type FlyoutBarItemProps = ItemOwnProps &
|
|
102
|
+
Omit<ComponentProps<"div">, keyof ItemOwnProps | "id" | "style">;
|
|
103
|
+
|
|
104
|
+
const itemClass = cn(
|
|
105
|
+
"flex min-w-10 cursor-default items-center justify-center gap-2 px-2.5 text-sm select-none",
|
|
106
|
+
"transition-colors duration-(--duration-fast) ease-standard",
|
|
107
|
+
"data-disabled:text-ink-disabled data-highlighted:text-ink",
|
|
108
|
+
"[&_svg]:size-5 [&_svg]:shrink-0",
|
|
109
|
+
focusRingInset,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/** 展开条里的一项:子元素是图标,可以再带文字;传 `href` 就是一个链接。 */
|
|
113
|
+
export function FlyoutBarItem({
|
|
114
|
+
"aria-label": ariaLabel,
|
|
115
|
+
disabled,
|
|
116
|
+
closeOnClick,
|
|
117
|
+
href,
|
|
118
|
+
target,
|
|
119
|
+
rel,
|
|
120
|
+
render,
|
|
121
|
+
onClick,
|
|
122
|
+
className,
|
|
123
|
+
children,
|
|
124
|
+
...props
|
|
125
|
+
}: FlyoutBarItemProps) {
|
|
126
|
+
const classes = cn(itemClass, className);
|
|
127
|
+
|
|
128
|
+
if (href !== undefined || render !== undefined) {
|
|
129
|
+
return (
|
|
130
|
+
<BaseMenu.LinkItem
|
|
131
|
+
render={render}
|
|
132
|
+
{...defined({ href, target, rel })}
|
|
133
|
+
aria-label={ariaLabel}
|
|
134
|
+
// 只有图标时没有文字可供"按字母跳转",用名称代替
|
|
135
|
+
label={ariaLabel}
|
|
136
|
+
closeOnClick={closeOnClick}
|
|
137
|
+
onClick={onClick}
|
|
138
|
+
className={classes}
|
|
139
|
+
>
|
|
140
|
+
{children}
|
|
141
|
+
</BaseMenu.LinkItem>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<BaseMenu.Item
|
|
147
|
+
{...props}
|
|
148
|
+
aria-label={ariaLabel}
|
|
149
|
+
label={ariaLabel}
|
|
150
|
+
disabled={disabled}
|
|
151
|
+
closeOnClick={closeOnClick}
|
|
152
|
+
onClick={onClick}
|
|
153
|
+
className={classes}
|
|
154
|
+
>
|
|
155
|
+
{children}
|
|
156
|
+
</BaseMenu.Item>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { decor } from "../../lib/decor";
|
|
4
|
+
|
|
5
|
+
export type GhostTextVariant = "hatch" | "outline" | "solid";
|
|
6
|
+
|
|
7
|
+
export type GhostTextProps = ComponentProps<"span"> & {
|
|
8
|
+
/**
|
|
9
|
+
* - `hatch` 字形透明,用细斜纹填充。版块分隔、背景,默认;
|
|
10
|
+
* - `outline` 字形透明,只有一圈浅色描边。章节编号、完成态的庆祝词;
|
|
11
|
+
* - `solid` 纯墨色,越过容器边界被截断。
|
|
12
|
+
*/
|
|
13
|
+
variant?: GhostTextVariant;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const variantClass: Record<GhostTextVariant, string> = {
|
|
17
|
+
hatch: "ghost-hatch",
|
|
18
|
+
outline: "ghost-outline",
|
|
19
|
+
solid: "text-ink",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 镂空巨字:版面背景层里的一个词或一个编号,不是标题。
|
|
24
|
+
* 它只管长相与隐藏:放在哪、被谁裁切由使用方定——通常是绝对定位在一个
|
|
25
|
+
* `relative overflow-clip` 的版块里。字号默认是 `text-ghost`,用 `className` 换。
|
|
26
|
+
*
|
|
27
|
+
* 内容要短,写与版块相关的真实词语;一个版块一个;窄屏上往往直接去掉更好。
|
|
28
|
+
*/
|
|
29
|
+
export function GhostText({
|
|
30
|
+
variant = "hatch",
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}: GhostTextProps) {
|
|
34
|
+
return (
|
|
35
|
+
<span
|
|
36
|
+
{...props}
|
|
37
|
+
aria-hidden="true"
|
|
38
|
+
data-variant={variant}
|
|
39
|
+
className={cn(
|
|
40
|
+
decor,
|
|
41
|
+
"block font-display text-ghost font-extrabold whitespace-nowrap uppercase",
|
|
42
|
+
variantClass[variant],
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -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 HatchDensity = "fine" | "mid" | "bold";
|
|
6
|
+
|
|
7
|
+
export type HatchProps = Omit<ComponentProps<"div">, "children"> & {
|
|
8
|
+
/**
|
|
9
|
+
* - `bold` 粗而淡的斜带(墨色 5%),跟随主题。条带状的表面:工具条、标题栏,默认;
|
|
10
|
+
* - `mid` 比底色亮一档的斜带,压在深色控件上,不随主题变;
|
|
11
|
+
* - `fine` 很细的墨色斜带,填未探索的区域、需要"留空"的格子。
|
|
12
|
+
*/
|
|
13
|
+
density?: HatchDensity;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const densityClass: Record<HatchDensity, string> = {
|
|
17
|
+
bold: "hatch",
|
|
18
|
+
mid: "hatch hatch-mid",
|
|
19
|
+
fine: "hatch hatch-fine",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 一块 45° 斜纹,纯装饰。尺寸、位置、底色都由 `className` 给。
|
|
24
|
+
* 把它限制在条带和控件上:不要铺满整个页面,也不要压在正文下面。
|
|
25
|
+
*/
|
|
26
|
+
export function Hatch({ density = "bold", className, ...props }: HatchProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
{...props}
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
data-density={density}
|
|
32
|
+
className={cn(decor, densityClass[density], className)}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Hatch, type HatchDensity, type HatchProps } from "./Hatch";
|