summaryrefslogtreecommitdiff
path: root/src/components/shared/PageHeader.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/shared/PageHeader.tsx')
-rw-r--r--src/components/shared/PageHeader.tsx206
1 files changed, 206 insertions, 0 deletions
diff --git a/src/components/shared/PageHeader.tsx b/src/components/shared/PageHeader.tsx
new file mode 100644
index 0000000..69cb7ab
--- /dev/null
+++ b/src/components/shared/PageHeader.tsx
@@ -0,0 +1,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;