- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 0.46 → 0.16,悬停加深,聚焦 / 出错保留光晕) - 新增 SegmentedControl(滑动 seg-thumb),替换站点、下载、订阅、文件、智能体列表及对话框里的实心选中块;Tabs 改为同一枚滑块 - 开关型按钮统一 outline + aria-pressed 选中外观;页头 / 工具栏次操作描边、图标钮描边圆;文件排序改 Select;下载总速度加标签 - 分页一页放得下时只留总数、空列表不渲染、按容器宽度收起按钮 - 运行状态存储区重排;智能体列表页与会话页共用外壳路由,标题与左栏不再重挂载 - 站点卡片「⋯」不再单独换行;设置导航、状态计数、多选条件改玻璃选中态 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
139 lines
5.0 KiB
TypeScript
139 lines
5.0 KiB
TypeScript
import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react";
|
|
|
|
import { cn } from "~/lib/utils.ts";
|
|
|
|
type Thumb = { x: number; y: number; w: number; h: number };
|
|
|
|
/**
|
|
* 分段控件的滑块:量出容器里当前选中项(`selector`)的位置写进 style,选中项变化、尺寸变化时跟着滑过去。
|
|
* 第一次量到之前不画,量到后才挂过渡,避免首屏从左上角滑进来。与 uhd_web SegmentedTabs、控制台侧栏同一做法。
|
|
*/
|
|
export function useSlidingThumb(container: RefObject<HTMLElement | null>, selector: string, deps: unknown[] = []) {
|
|
const [thumb, setThumb] = useState<Thumb | null>(null);
|
|
const [animate, setAnimate] = useState(false);
|
|
useLayoutEffect(() => {
|
|
const el = container.current;
|
|
if (!el) return;
|
|
const measure = () => {
|
|
const active = el.querySelector<HTMLElement>(selector);
|
|
setThumb((prev) => {
|
|
if (!active) return null;
|
|
const next = { x: active.offsetLeft, y: active.offsetTop, w: active.offsetWidth, h: active.offsetHeight };
|
|
return prev && prev.x === next.x && prev.y === next.y && prev.w === next.w && prev.h === next.h ? prev : next;
|
|
});
|
|
};
|
|
measure();
|
|
const raf = requestAnimationFrame(() => setAnimate(true));
|
|
const ro = new ResizeObserver(measure);
|
|
ro.observe(el);
|
|
for (const child of Array.from(el.children)) ro.observe(child);
|
|
// radix Tabs 的选中态写在 data-state 上,不一定引起本组件重渲染
|
|
const mo = new MutationObserver(measure);
|
|
mo.observe(el, { subtree: true, attributes: true, attributeFilter: ["data-state", "aria-checked"] });
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
ro.disconnect();
|
|
mo.disconnect();
|
|
};
|
|
}, [container, selector, ...deps]);
|
|
return { thumb, animate };
|
|
}
|
|
|
|
/** 滑块本体(`seg-thumb` 玻璃),放在相对定位的容器里、选项之前 */
|
|
export function SegThumb({ thumb, animate, className }: { thumb: Thumb | null; animate: boolean; className?: string }) {
|
|
if (!thumb) return null;
|
|
return (
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"seg-thumb pointer-events-none absolute top-0 left-0 rounded-full",
|
|
animate && "tab-slider",
|
|
className,
|
|
)}
|
|
style={{ transform: `translate(${thumb.x}px, ${thumb.y}px)`, width: thumb.w, height: thumb.h }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export type SegmentedOption<V extends string> = {
|
|
value: V;
|
|
label: ReactNode;
|
|
icon?: ReactNode;
|
|
/** 跟在标签后面的数量,弱一档显示 */
|
|
count?: ReactNode;
|
|
/** 只有图标时给读屏与悬停提示 */
|
|
title?: string;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
/**
|
|
* 单选分段控件:筛选(全部 / 已启用)、视图(卡片 / 列表)、分组(进行中 / 已完成)这类互斥选项。
|
|
* 选中项由一枚滑动的 `seg-thumb` 承担,文字提一档;没有实心选中块。多选的筛选条件用 `pill-selected` 胶囊,不用它。
|
|
*/
|
|
export function SegmentedControl<V extends string>({
|
|
value,
|
|
onChange,
|
|
options,
|
|
label,
|
|
size = "sm",
|
|
className,
|
|
fill = false,
|
|
}: {
|
|
value: V;
|
|
onChange: (v: V) => void;
|
|
options: SegmentedOption<V>[];
|
|
/** 读屏用的组名 */
|
|
label: string;
|
|
size?: "xs" | "sm";
|
|
className?: string;
|
|
/** 铺满容器宽度,选项等分 */
|
|
fill?: boolean;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const { thumb, animate } = useSlidingThumb(ref, '[aria-checked="true"]', [value, options.length]);
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
role="radiogroup"
|
|
aria-label={label}
|
|
className={cn(
|
|
"relative isolate items-center gap-0.5",
|
|
fill ? "flex w-full" : "inline-flex max-w-full",
|
|
className,
|
|
)}
|
|
>
|
|
<SegThumb thumb={thumb} animate={animate} className="-z-[1]" />
|
|
{options.map((o) => {
|
|
const on = o.value === value;
|
|
return (
|
|
<button
|
|
key={o.value}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={on}
|
|
aria-label={o.title}
|
|
title={o.title}
|
|
disabled={o.disabled}
|
|
onClick={() => onChange(o.value)}
|
|
className={cn(
|
|
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-full font-medium whitespace-nowrap outline-none transition-colors duration-base focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-3.5 [&_svg]:shrink-0",
|
|
size === "xs" ? "h-7 px-2.5 text-meta" : "h-8 px-3 text-caption",
|
|
o.label === "" && (size === "xs" ? "w-7 px-0" : "w-8 px-0"),
|
|
fill && "flex-1",
|
|
on ? "text-foreground" : "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
{o.icon}
|
|
{o.label === "" ? null : o.label}
|
|
{o.count != null ? (
|
|
<span className={cn("tabular-nums", on ? "text-muted-foreground" : "text-muted-foreground/70")}>
|
|
{o.count}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|