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, selector: string, deps: unknown[] = []) { const [thumb, setThumb] = useState(null); const [animate, setAnimate] = useState(false); useLayoutEffect(() => { const el = container.current; if (!el) return; const measure = () => { const active = el.querySelector(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 ( ); } export type SegmentedOption = { value: V; label: ReactNode; icon?: ReactNode; /** 跟在标签后面的数量,弱一档显示 */ count?: ReactNode; /** 只有图标时给读屏与悬停提示 */ title?: string; disabled?: boolean; }; /** * 单选分段控件:筛选(全部 / 已启用)、视图(卡片 / 列表)、分组(进行中 / 已完成)这类互斥选项。 * 选中项由一枚滑动的 `seg-thumb` 承担,文字提一档;没有实心选中块。多选的筛选条件用 `pill-selected` 胶囊,不用它。 */ export function SegmentedControl({ value, onChange, options, label, size = "sm", className, fill = false, }: { value: V; onChange: (v: V) => void; options: SegmentedOption[]; /** 读屏用的组名 */ label: string; size?: "xs" | "sm"; className?: string; /** 铺满容器宽度,选项等分 */ fill?: boolean; }) { const ref = useRef(null); const { thumb, animate } = useSlidingThumb(ref, '[aria-checked="true"]', [value, options.length]); return (
{options.map((o) => { const on = o.value === value; return ( ); })}
); }