1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
|
import cx from 'clsx';
import * as React from 'react';
import { Search } from '~/components/shared/FeatherIcons';
import s from './PageHeader.module.scss';
/**
* 页面顶栏的一套零件,代理 / 连接 / 规则 / 日志四个页面共用,
* 样式和断点行为全在 PageHeader.module.scss 里。
*
* 每个零件都接 className,页面自己的模块可以往上叠特有的东西
* (比如连接页窄屏下要重排 order)。
*/
export function PageHeader({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <header className={cx(s.header, className)}>{children}</header>;
}
export function HeaderTitle({ children }: { children: React.ReactNode }) {
return <h1 className={s.title}>{children}</h1>;
}
/** 窄屏下强制换行,让它后面的东西独占下一行 */
export function HeaderRowBreak({ className }: { className?: string }) {
return <span className={cx(s.rowBreak, className)} aria-hidden />;
}
export function HeaderTabs({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cx(s.tabs, className)} role="tablist" aria-label={label}>
{children}
</div>
);
}
/** 计数超过三位就收成 999+,否则标签宽度会跟着数字跳 */
function formatQty(n: number) {
return n < 1000 ? String(n) : '999+';
}
export function HeaderTab({
active,
label,
count,
onClick,
}: {
active: boolean;
label: string;
/** 省略则不显示计数 */
count?: number;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={cx(s.tab, { [s.tabActive]: active })}
onClick={onClick}
>
{label}
{typeof count === 'number' ? <span className={s.tabCount}>{formatQty(count)}</span> : null}
</button>
);
}
/**
* 搜索框。传 value/onChange 就是受控输入,传 children 则由调用方
* 自己塞输入组件(比如挂在 jotai atom 上的 TextFilter)。
*/
export function HeaderSearch({
placeholder,
value,
onChange,
children,
className,
}: {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
children?: React.ReactNode;
className?: string;
}) {
return (
<div className={cx(s.search, className)}>
<Search size={15} className={s.searchIcon} aria-hidden />
{children ?? (
<input
type="text"
name="filter"
autoComplete="off"
value={value}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
/>
)}
</div>
);
}
export function HeaderActions({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cx(s.actions, className)}>{children}</div>;
}
type ButtonVariant = 'ghost' | 'primary' | 'danger' | 'paused';
const variantClass: Record<ButtonVariant, string> = {
ghost: s.btnGhost,
primary: s.btnPrimary,
danger: s.btnDanger,
paused: s.btnPaused,
};
export function HeaderButton({
variant = 'ghost',
icon,
label,
/** 悬停提示,省略则用 label。展开说明按钮作用时才需要单独给 */
title,
/**
* 文字收起的宽度门槛。'sm' 只在窄屏收(默认,适合主要操作),
* 'md' 中等宽度就收(次要操作,给主要操作腾地方)
*/
hideLabelAt = 'sm',
busy,
disabled,
onClick,
}: {
variant?: ButtonVariant;
icon: React.ReactNode;
label: string;
title?: string;
hideLabelAt?: 'sm' | 'md';
busy?: boolean;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
className={cx(s.btn, variantClass[variant], { [s.btnBusy]: busy })}
onClick={onClick}
disabled={disabled}
// 文字会在窄屏被藏掉,读屏和 tooltip 都得靠这两个属性兜住
aria-label={label}
title={title ?? label}
>
{icon}
<span className={hideLabelAt === 'md' ? s.btnText : s.btnTextSm}>{label}</span>
</button>
);
}
export function HeaderIconButton({
icon,
label,
active,
expanded,
onClick,
}: {
icon: React.ReactNode;
label: string;
active?: boolean;
expanded?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
className={cx(s.iconBtn, { [s.iconBtnActive]: active })}
onClick={onClick}
aria-label={label}
aria-expanded={expanded}
title={label}
>
{icon}
</button>
);
}
/** 顶栏里的 <Select> 要叠的类名,把它拉齐到按钮那套尺寸 */
export const headerSelectClass = s.select;
/** 搜索旁边还并排放了别的控件时,叠在 HeaderSearch 上 */
export const headerSearchInlineClass = s.searchInline;
|