chore(web-platform): release V0.6.1 工程质量优化
This commit is contained in:
@@ -1,2 +1,27 @@
|
||||
import type {ReactNode} from 'react';
|
||||
export function Badge({children,tone='neutral',className='',title}:{children:ReactNode;tone?:'neutral'|'accent'|'success'|'warning';className?:string;title?:string}){const toneClass={neutral:'border-border bg-surface text-text-secondary',accent:'border-success-border bg-accent-soft text-accent',success:'border-success-border bg-success-soft text-success',warning:'border-warning-border bg-warning-soft text-warning'}[tone];return <span title={title} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}>{children}</span>;}
|
||||
import type { ReactNode } from 'react';
|
||||
export function Badge({
|
||||
children,
|
||||
tone = 'neutral',
|
||||
className = '',
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: 'neutral' | 'accent' | 'success' | 'warning';
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const toneClass = {
|
||||
neutral: 'border-border bg-surface text-text-secondary',
|
||||
accent: 'border-success-border bg-accent-soft text-accent',
|
||||
success: 'border-success-border bg-success-soft text-success',
|
||||
warning: 'border-warning-border bg-warning-soft text-warning',
|
||||
}[tone];
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
import type {ButtonHTMLAttributes,ReactNode} from 'react';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{
|
||||
variant?:'primary'|'secondary'|'ghost'|'danger';
|
||||
size?:'sm'|'md'|'icon';
|
||||
icon?:ReactNode;
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'icon';
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function Button({variant='secondary',size='sm',icon,className='',children,type='button',...props}:ButtonProps){
|
||||
const variants={
|
||||
primary:'border-transparent bg-accent text-white hover:bg-accent-hover',
|
||||
secondary:'border-border bg-surface text-text-primary hover:bg-element-hover',
|
||||
ghost:'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary',
|
||||
danger:'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white',
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
size = 'sm',
|
||||
icon,
|
||||
className = '',
|
||||
children,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const variants = {
|
||||
primary: 'border-transparent bg-accent text-white hover:bg-accent-hover',
|
||||
secondary: 'border-border bg-surface text-text-primary hover:bg-element-hover',
|
||||
ghost:
|
||||
'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary',
|
||||
danger: 'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white',
|
||||
};
|
||||
const sizes={sm:'h-7 gap-1.5 rounded-md px-2 text-xs',md:'h-8 gap-2 rounded-md px-3 text-sm',icon:'h-7 w-7 rounded-md p-0'};
|
||||
return <button type={type} className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()} {...props}>{icon&&<span aria-hidden="true" className="flex items-center">{icon}</span>}{children}</button>;
|
||||
const sizes = {
|
||||
sm: 'h-7 gap-1.5 rounded-md px-2 text-xs',
|
||||
md: 'h-8 gap-2 rounded-md px-3 text-sm',
|
||||
icon: 'h-7 w-7 rounded-md p-0',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<span aria-hidden="true" className="flex items-center">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {CollapsibleSection} from './CollapsibleSection';
|
||||
describe('CollapsibleSection',()=>{it('遵循默认折叠状态并可展开',()=>{render(<CollapsibleSection title="低频设置" defaultOpen={false}><span>内容</span></CollapsibleSection>);const trigger=screen.getByRole('button',{name:'低频设置'});expect(trigger).toHaveAttribute('aria-expanded','false');expect(screen.queryByText('内容')).not.toBeInTheDocument();fireEvent.click(trigger);expect(trigger).toHaveAttribute('aria-expanded','true');expect(screen.getByText('内容')).toBeVisible();});it('forceOpen 时保持内容可见',()=>{render(<CollapsibleSection title="警告" defaultOpen={false} forceOpen><span>错误详情</span></CollapsibleSection>);expect(screen.getByText('错误详情')).toBeVisible();});});
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
describe('CollapsibleSection', () => {
|
||||
it('遵循默认折叠状态并可展开', () => {
|
||||
render(
|
||||
<CollapsibleSection title="低频设置" defaultOpen={false}>
|
||||
<span>内容</span>
|
||||
</CollapsibleSection>,
|
||||
);
|
||||
const trigger = screen.getByRole('button', { name: '低频设置' });
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText('内容')).not.toBeInTheDocument();
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('内容')).toBeVisible();
|
||||
});
|
||||
it('forceOpen 时保持内容可见', () => {
|
||||
render(
|
||||
<CollapsibleSection title="警告" defaultOpen={false} forceOpen>
|
||||
<span>错误详情</span>
|
||||
</CollapsibleSection>,
|
||||
);
|
||||
expect(screen.getByText('错误详情')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import {useState,type ReactNode} from 'react';
|
||||
import {ChevronRight} from 'lucide-react';
|
||||
export function CollapsibleSection({title,children,defaultOpen=true,forceOpen=false,badge}:{title:string;children:ReactNode;defaultOpen?:boolean;forceOpen?:boolean;badge?:ReactNode}){
|
||||
const [open,setOpen]=useState(defaultOpen);const expanded=forceOpen||open;
|
||||
return <section className="border-b border-border">
|
||||
<button type="button" aria-expanded={expanded} onClick={()=>setOpen(value=>!value)} className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30">
|
||||
<ChevronRight aria-hidden="true" className={`h-3.5 w-3.5 transition-transform ${expanded?'rotate-90':''}`}/><span className="min-w-0 flex-1 truncate">{title}</span>{badge}
|
||||
</button>
|
||||
{expanded&&<div className="px-3 pb-3">{children}</div>}
|
||||
</section>;
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
export function CollapsibleSection({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
forceOpen = false,
|
||||
badge,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
forceOpen?: boolean;
|
||||
badge?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const expanded = forceOpen || open;
|
||||
return (
|
||||
<section className="border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{title}</span>
|
||||
{badge}
|
||||
</button>
|
||||
{expanded && <div className="px-3 pb-3">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {Button} from './Button';
|
||||
import {Dialog} from './Dialog';
|
||||
export function ConfirmDialog({open,title,children,confirmLabel='确认',cancelLabel='取消',danger=false,onConfirm,onClose}:{open:boolean;title:string;children:ReactNode;confirmLabel?:string;cancelLabel?:string;danger?:boolean;onConfirm:()=>void;onClose:()=>void}){return <Dialog open={open} onClose={onClose} title={title} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>{cancelLabel}</Button><Button variant={danger?'danger':'primary'} onClick={onConfirm}>{confirmLabel}</Button></div>}>{children}</Dialog>;}
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from './Button';
|
||||
import { Dialog } from './Dialog';
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
confirmLabel = '确认',
|
||||
cancelLabel = '取消',
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={onClose}>{cancelLabel}</Button>
|
||||
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Check,Copy} from 'lucide-react';
|
||||
import {IconButton} from './IconButton';
|
||||
export function CopyButton({value,label='复制'}:{value:string;label?:string}){const [copied,setCopied]=useState(false);useEffect(()=>{if(!copied)return;const timer=window.setTimeout(()=>setCopied(false),1200);return()=>window.clearTimeout(timer);},[copied]);return <IconButton aria-label={copied?'已复制':label} tooltip={copied?'已复制':label} onClick={()=>void (async()=>{try{if(!navigator.clipboard?.writeText)return;await navigator.clipboard.writeText(value);setCopied(true);}catch{setCopied(false);}})()} className="h-5 w-5">{copied?<Check className="h-3 w-3 text-success"/>:<Copy className="h-3 w-3"/>}</IconButton>;}
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { IconButton } from './IconButton';
|
||||
export function CopyButton({ value, label = '复制' }: { value: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timer = window.setTimeout(() => setCopied(false), 1200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copied]);
|
||||
return (
|
||||
<IconButton
|
||||
aria-label={copied ? '已复制' : label}
|
||||
tooltip={copied ? '已复制' : label}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
})()
|
||||
}
|
||||
className="h-5 w-5"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-success" /> : <Copy className="h-3 w-3" />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {useState} from 'react';
|
||||
import {Dialog} from './Dialog';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { Dialog } from './Dialog';
|
||||
|
||||
function Fixture(){const [open,setOpen]=useState(false);return <><button onClick={()=>setOpen(true)}>打开</button><Dialog open={open} onClose={()=>setOpen(false)} title="入口选择"><button>第一个</button><button>最后一个</button></Dialog></>;}
|
||||
describe('Dialog',()=>{it('支持 Escape 关闭并恢复触发器焦点',()=>{render(<Fixture/>);const trigger=screen.getByRole('button',{name:'打开'});trigger.focus();fireEvent.click(trigger);expect(screen.getByRole('dialog')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(screen.queryByRole('dialog')).not.toBeInTheDocument();expect(trigger).toHaveFocus();});it('将 Tab 焦点限制在弹窗内',()=>{render(<Fixture/>);fireEvent.click(screen.getByRole('button',{name:'打开'}));const first=screen.getByRole('button',{name:'关闭'}),last=screen.getByRole('button',{name:'最后一个'});last.focus();fireEvent.keyDown(document,{key:'Tab'});expect(first).toHaveFocus();first.focus();fireEvent.keyDown(document,{key:'Tab',shiftKey:true});expect(last).toHaveFocus();});it('全屏时将 Portal 挂载到全屏元素内部',()=>{const host=document.createElement('div');document.body.append(host);Object.defineProperty(document,'fullscreenElement',{configurable:true,value:host});const {unmount}=render(<Dialog open onClose={()=>{}} title="全屏弹窗">内容</Dialog>);expect(host).toContainElement(screen.getByRole('dialog'));unmount();Object.defineProperty(document,'fullscreenElement',{configurable:true,value:null});host.remove();});});
|
||||
function Fixture() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>打开</button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)} title="入口选择">
|
||||
<button>第一个</button>
|
||||
<button>最后一个</button>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
describe('Dialog', () => {
|
||||
it('支持 Escape 关闭并恢复触发器焦点', () => {
|
||||
render(<Fixture />);
|
||||
const trigger = screen.getByRole('button', { name: '打开' });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
it('将 Tab 焦点限制在弹窗内', () => {
|
||||
render(<Fixture />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开' }));
|
||||
const first = screen.getByRole('button', { name: '关闭' }),
|
||||
last = screen.getByRole('button', { name: '最后一个' });
|
||||
last.focus();
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
expect(first).toHaveFocus();
|
||||
first.focus();
|
||||
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
|
||||
expect(last).toHaveFocus();
|
||||
});
|
||||
it('全屏时将 Portal 挂载到全屏元素内部', () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: host });
|
||||
const { unmount } = render(
|
||||
<Dialog open onClose={() => {}} title="全屏弹窗">
|
||||
内容
|
||||
</Dialog>,
|
||||
);
|
||||
expect(host).toContainElement(screen.getByRole('dialog'));
|
||||
unmount();
|
||||
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null });
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,105 @@
|
||||
import {useEffect,useId,useRef,type ReactNode} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {X} from 'lucide-react';
|
||||
import {IconButton} from './IconButton';
|
||||
import { useEffect, useId, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
const FOCUSABLE='button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
export function Dialog({open,onClose,title,children,footer,className='',closable=true}:{open:boolean;onClose:()=>void;title:string;children:ReactNode;footer?:ReactNode;className?:string;closable?:boolean}){
|
||||
const ref=useRef<HTMLDivElement>(null),previous=useRef<HTMLElement|null>(null),onCloseRef=useRef(onClose),titleId=useId();
|
||||
useEffect(()=>{onCloseRef.current=onClose;},[onClose]);
|
||||
useEffect(()=>{if(!open)return;previous.current=document.activeElement instanceof HTMLElement?document.activeElement:null;ref.current?.focus();const key=(event:KeyboardEvent)=>{if(event.key==='Escape'&&closable){event.preventDefault();onCloseRef.current();return;}if(event.key!=='Tab'||!ref.current)return;const items=Array.from(ref.current.querySelectorAll<HTMLElement>(FOCUSABLE));if(!items.length){event.preventDefault();ref.current.focus();return;}const first=items[0],last=items.at(-1)!;if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);if(previous.current&&document.contains(previous.current))previous.current.focus();};},[open,closable]);
|
||||
if(!open)return null;
|
||||
const backdrop=<div aria-hidden="true" className="absolute inset-0 bg-black/55 backdrop-blur-[1px]" onMouseDown={event=>{if(closable&&event.target===event.currentTarget)onCloseRef.current();}}/>;
|
||||
return createPortal(<div className="fixed inset-0 z-[400] grid place-items-center p-6" role="presentation">{backdrop}<div ref={ref} tabIndex={-1} role="dialog" aria-modal="true" aria-labelledby={titleId} className={`relative flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-2xl focus:outline-none ${className}`}><header className="flex h-11 shrink-0 items-center justify-between border-b border-border bg-surface px-4"><h2 id={titleId} className="truncate text-sm font-semibold text-text-primary">{title}</h2>{closable&&<IconButton aria-label="关闭" tooltip="关闭" onClick={()=>onCloseRef.current()}><X className="h-4 w-4"/></IconButton>}</header><div className="overflow-y-auto p-4">{children}</div>{footer&&<footer className="border-t border-border bg-surface px-4 py-3">{footer}</footer>}</div></div>,document.fullscreenElement??document.body);
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
className = '',
|
||||
closable = true,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
closable?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null),
|
||||
previous = useRef<HTMLElement | null>(null),
|
||||
onCloseRef = useRef(onClose),
|
||||
titleId = useId();
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previous.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
ref.current?.focus();
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && closable) {
|
||||
event.preventDefault();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab' || !ref.current) return;
|
||||
const items = Array.from(ref.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (!items.length) {
|
||||
event.preventDefault();
|
||||
ref.current.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0],
|
||||
last = items.at(-1)!;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', key);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', key);
|
||||
if (previous.current && document.contains(previous.current)) previous.current.focus();
|
||||
};
|
||||
}, [open, closable]);
|
||||
if (!open) return null;
|
||||
const backdrop = (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/55 backdrop-blur-[1px]"
|
||||
onMouseDown={(event) => {
|
||||
if (closable && event.target === event.currentTarget) onCloseRef.current();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[400] grid place-items-center p-6" role="presentation">
|
||||
{backdrop}
|
||||
<div
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className={`relative flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-2xl focus:outline-none ${className}`}
|
||||
>
|
||||
<header className="flex h-11 shrink-0 items-center justify-between border-b border-border bg-surface px-4">
|
||||
<h2 id={titleId} className="truncate text-sm font-semibold text-text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
{closable && (
|
||||
<IconButton aria-label="关闭" tooltip="关闭" onClick={() => onCloseRef.current()}>
|
||||
<X className="h-4 w-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
</header>
|
||||
<div className="overflow-y-auto p-4">{children}</div>
|
||||
{footer && (
|
||||
<footer className="border-t border-border bg-surface px-4 py-3">{footer}</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.fullscreenElement ?? document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,85 @@
|
||||
import {useEffect,useRef,type ReactNode} from 'react';
|
||||
import {MoreHorizontal} from 'lucide-react';
|
||||
import {IconButton} from './IconButton';
|
||||
import {Popover} from './Popover';
|
||||
export interface DropdownMenuItem{id:string;label:string;icon?:ReactNode;disabled?:boolean;onSelect:()=>void;}
|
||||
function MenuContent({items,close}:{items:DropdownMenuItem[];close:()=>void}){const refs=useRef<(HTMLButtonElement|null)[]>([]);useEffect(()=>{requestAnimationFrame(()=>refs.current.find(item=>item&&!item.disabled)?.focus());},[]);return <div role="menu" className="w-48 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl" onKeyDown={event=>{const enabled=refs.current.filter((item):item is HTMLButtonElement=>Boolean(item&&!item.disabled)),index=enabled.indexOf(document.activeElement as HTMLButtonElement);if(event.key==='ArrowDown'){event.preventDefault();enabled[(index+1)%enabled.length]?.focus();}else if(event.key==='ArrowUp'){event.preventDefault();enabled[(index-1+enabled.length)%enabled.length]?.focus();}else if(event.key==='Home'){event.preventDefault();enabled[0]?.focus();}else if(event.key==='End'){event.preventDefault();enabled.at(-1)?.focus();}}}>{items.map((item,index)=><button key={item.id} ref={node=>{refs.current[index]=node;}} role="menuitem" disabled={item.disabled} onClick={()=>{close();item.onSelect();}} className="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs text-text-secondary hover:bg-element-hover hover:text-text-primary focus:bg-element-hover focus:outline-none disabled:opacity-40"><span className="flex h-4 w-4 items-center justify-center">{item.icon}</span>{item.label}</button>)}</div>;}
|
||||
export function DropdownMenu({items,label='更多操作',className=''}:{items:DropdownMenuItem[];label?:string;className?:string}){return <span className={className}><Popover label={label} trigger={({open,toggle})=><IconButton aria-label={label} aria-expanded={open} tooltip={label} onClick={toggle}><MoreHorizontal className="h-4 w-4"/></IconButton>}>{({close})=><MenuContent items={items} close={close}/>}</Popover></span>;}
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { IconButton } from './IconButton';
|
||||
import { Popover } from './Popover';
|
||||
export interface DropdownMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
function MenuContent({ items, close }: { items: DropdownMenuItem[]; close: () => void }) {
|
||||
const refs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => refs.current.find((item) => item && !item.disabled)?.focus());
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
role="menu"
|
||||
className="w-48 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl"
|
||||
onKeyDown={(event) => {
|
||||
const enabled = refs.current.filter((item): item is HTMLButtonElement =>
|
||||
Boolean(item && !item.disabled),
|
||||
),
|
||||
index = enabled.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
enabled[(index + 1) % enabled.length]?.focus();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
enabled[(index - 1 + enabled.length) % enabled.length]?.focus();
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
enabled[0]?.focus();
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
enabled.at(-1)?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={(node) => {
|
||||
refs.current[index] = node;
|
||||
}}
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
close();
|
||||
item.onSelect();
|
||||
}}
|
||||
className="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs text-text-secondary hover:bg-element-hover hover:text-text-primary focus:bg-element-hover focus:outline-none disabled:opacity-40"
|
||||
>
|
||||
<span className="flex h-4 w-4 items-center justify-center">{item.icon}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function DropdownMenu({
|
||||
items,
|
||||
label = '更多操作',
|
||||
className = '',
|
||||
}: {
|
||||
items: DropdownMenuItem[];
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={className}>
|
||||
<Popover
|
||||
label={label}
|
||||
trigger={({ open, toggle }) => (
|
||||
<IconButton aria-label={label} aria-expanded={open} tooltip={label} onClick={toggle}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
>
|
||||
{({ close }) => <MenuContent items={items} close={close} />}
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
import {SearchX} from 'lucide-react';
|
||||
export function EmptySearchState({label='没有匹配结果'}:{label?:string}){return <div className="grid place-items-center gap-2 px-3 py-6 text-center text-xs text-text-tertiary"><SearchX className="h-5 w-5"/><span>{label}</span></div>;}
|
||||
import { SearchX } from 'lucide-react';
|
||||
export function EmptySearchState({ label = '没有匹配结果' }: { label?: string }) {
|
||||
return (
|
||||
<div className="grid place-items-center gap-2 px-3 py-6 text-center text-xs text-text-tertiary">
|
||||
<SearchX className="h-5 w-5" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,48 @@
|
||||
import {fireEvent,render,screen,waitFor} from '@testing-library/react';
|
||||
import {DropdownMenu,LiveRegion,ProgressBar,SearchableCombobox} from './index';
|
||||
describe('第五批基础 UI',()=>{
|
||||
it('下拉菜单打开后聚焦菜单项并恢复触发器焦点',async()=>{const run=vi.fn();render(<DropdownMenu items={[{id:'a',label:'动作 A',onSelect:run}]}/>);const trigger=screen.getByRole('button',{name:'更多操作'});trigger.focus();fireEvent.click(trigger);const item=screen.getByRole('menuitem',{name:'动作 A'});await waitFor(()=>expect(item).toHaveFocus());fireEvent.click(item);expect(run).toHaveBeenCalled();expect(trigger).toHaveFocus();});
|
||||
it('进度条和实时区域暴露状态',()=>{render(<><ProgressBar value={.42} label="编译模型"/><LiveRegion>正在编译模型</LiveRegion></>);expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow','42');expect(screen.getByRole('status')).toHaveTextContent('正在编译模型');});
|
||||
it('可搜索组合框筛选并选择入口',()=>{const change=vi.fn();render(<SearchableCombobox label="模型入口" value="a" onChange={change} options={[{value:'a',label:'模型 A',description:'a.xml'},{value:'b',label:'模型 B',description:'b.xml'}]}/>);fireEvent.click(screen.getByRole('button',{name:'模型入口'}));fireEvent.change(screen.getByRole('combobox',{name:'搜索模型入口'}),{target:{value:'B'}});const input=screen.getByRole('combobox',{name:'搜索模型入口'});expect(input).toHaveAttribute('aria-expanded','true');fireEvent.click(screen.getByRole('option',{name:/模型 B/}));expect(change).toHaveBeenCalledWith('b');});
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { DropdownMenu, LiveRegion, ProgressBar, SearchableCombobox } from './index';
|
||||
describe('第五批基础 UI', () => {
|
||||
it('下拉菜单打开后聚焦菜单项并恢复触发器焦点', async () => {
|
||||
const run = vi.fn();
|
||||
render(<DropdownMenu items={[{ id: 'a', label: '动作 A', onSelect: run }]} />);
|
||||
const trigger = screen.getByRole('button', { name: '更多操作' });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
const item = screen.getByRole('menuitem', { name: '动作 A' });
|
||||
await waitFor(() => expect(item).toHaveFocus());
|
||||
fireEvent.click(item);
|
||||
expect(run).toHaveBeenCalled();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
it('进度条和实时区域暴露状态', () => {
|
||||
render(
|
||||
<>
|
||||
<ProgressBar value={0.42} label="编译模型" />
|
||||
<LiveRegion>正在编译模型</LiveRegion>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42');
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在编译模型');
|
||||
});
|
||||
it('可搜索组合框筛选并选择入口', () => {
|
||||
const change = vi.fn();
|
||||
render(
|
||||
<SearchableCombobox
|
||||
label="模型入口"
|
||||
value="a"
|
||||
onChange={change}
|
||||
options={[
|
||||
{ value: 'a', label: '模型 A', description: 'a.xml' },
|
||||
{ value: 'b', label: '模型 B', description: 'b.xml' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '模型入口' }));
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '搜索模型入口' }), {
|
||||
target: { value: 'B' },
|
||||
});
|
||||
const input = screen.getByRole('combobox', { name: '搜索模型入口' });
|
||||
expect(input).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(screen.getByRole('option', { name: /模型 B/ }));
|
||||
expect(change).toHaveBeenCalledWith('b');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import type {ButtonHTMLAttributes} from 'react';
|
||||
import {Tooltip} from './Tooltip';
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{active?:boolean;tooltip?:string;}
|
||||
export function IconButton({active=false,tooltip,className='',type='button',...props}:IconButtonProps){
|
||||
const button=<button type={type} aria-pressed={active||undefined} className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active?'border-accent/40 bg-accent-soft text-accent':'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()} {...props}/>;
|
||||
return tooltip?<Tooltip content={tooltip}>{button}</Tooltip>:button;
|
||||
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
active?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
export function IconButton({
|
||||
active = false,
|
||||
tooltip,
|
||||
className = '',
|
||||
type = 'button',
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
const button = (
|
||||
<button
|
||||
type={type}
|
||||
aria-pressed={active || undefined}
|
||||
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return tooltip ? <Tooltip content={tooltip}>{button}</Tooltip> : button;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
import type {ReactNode} from 'react';
|
||||
export function Kbd({children}:{children:ReactNode}){return <kbd className="inline-flex min-w-5 items-center justify-center rounded border border-border-strong bg-surface px-1.5 py-0.5 font-mono text-[10px] leading-4 text-text-secondary shadow-sm">{children}</kbd>;}
|
||||
import type { ReactNode } from 'react';
|
||||
export function Kbd({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<kbd className="inline-flex min-w-5 items-center justify-center rounded border border-border-strong bg-surface px-1.5 py-0.5 font-mono text-[10px] leading-4 text-text-secondary shadow-sm">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,19 @@
|
||||
import type {ReactNode} from 'react';
|
||||
export function LiveRegion({children,assertive=false}:{children:ReactNode;assertive?:boolean}){return <div className="sr-only" role={assertive?'alert':'status'} aria-live={assertive?'assertive':'polite'} aria-atomic="true">{children}</div>;}
|
||||
import type { ReactNode } from 'react';
|
||||
export function LiveRegion({
|
||||
children,
|
||||
assertive = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
assertive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="sr-only"
|
||||
role={assertive ? 'alert' : 'status'}
|
||||
aria-live={assertive ? 'assertive' : 'polite'}
|
||||
aria-atomic="true"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,66 @@
|
||||
/* eslint-disable react-hooks/refs -- refs are read only inside event callbacks passed to render props */
|
||||
import {useCallback,useEffect,useRef,useState,type ReactNode} from 'react';
|
||||
export function Popover({trigger,children,placement='bottom-right',label}:{trigger:(props:{open:boolean;toggle:()=>void})=>ReactNode;children:(props:{close:(restoreFocus?:boolean)=>void})=>ReactNode;placement?:'bottom-right'|'bottom-left'|'top-left';label:string}){const [open,setOpen]=useState(false),root=useRef<HTMLDivElement>(null),previous=useRef<HTMLElement|null>(null);const close=useCallback((restoreFocus=true)=>{setOpen(false);if(restoreFocus)previous.current?.focus();},[]);useEffect(()=>{if(!open)return;const pointer=(event:PointerEvent)=>{if(!root.current?.contains(event.target as Node))setOpen(false);},key=(event:KeyboardEvent)=>{if(event.key==='Escape'||((event.ctrlKey||event.metaKey)&&event.key.toLocaleLowerCase()==='k')){setOpen(false);previous.current?.focus();}};document.addEventListener('pointerdown',pointer);document.addEventListener('keydown',key);return()=>{document.removeEventListener('pointerdown',pointer);document.removeEventListener('keydown',key);};},[open]);const position=placement==='top-left'?'bottom-8 left-0':placement==='bottom-left'?'left-0 top-9':'right-0 top-9';return <div ref={root} className="relative">{trigger({open,toggle:()=>{if(!open)previous.current=document.activeElement instanceof HTMLElement?document.activeElement:null;setOpen(value=>!value);}})}{open&&<section role="dialog" aria-label={label} className={`absolute z-50 ${position}`}>{children({close})}</section>}</div>;}
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
export function Popover({
|
||||
trigger,
|
||||
children,
|
||||
placement = 'bottom-right',
|
||||
label,
|
||||
}: {
|
||||
trigger: (props: { open: boolean; toggle: () => void }) => ReactNode;
|
||||
children: (props: { close: (restoreFocus?: boolean) => void }) => ReactNode;
|
||||
placement?: 'bottom-right' | 'bottom-left' | 'top-left';
|
||||
label: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false),
|
||||
root = useRef<HTMLDivElement>(null),
|
||||
previous = useRef<HTMLElement | null>(null);
|
||||
const close = useCallback((restoreFocus = true) => {
|
||||
setOpen(false);
|
||||
if (restoreFocus) previous.current?.focus();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const pointer = (event: PointerEvent) => {
|
||||
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
||||
},
|
||||
key = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === 'Escape' ||
|
||||
((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k')
|
||||
) {
|
||||
setOpen(false);
|
||||
previous.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', pointer);
|
||||
document.addEventListener('keydown', key);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', pointer);
|
||||
document.removeEventListener('keydown', key);
|
||||
};
|
||||
}, [open]);
|
||||
const position =
|
||||
placement === 'top-left'
|
||||
? 'bottom-8 left-0'
|
||||
: placement === 'bottom-left'
|
||||
? 'left-0 top-9'
|
||||
: 'right-0 top-9';
|
||||
return (
|
||||
<div ref={root} className="relative">
|
||||
{trigger({
|
||||
open,
|
||||
toggle: () => {
|
||||
if (!open)
|
||||
previous.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
setOpen((value) => !value);
|
||||
},
|
||||
})}
|
||||
{open && (
|
||||
<section role="dialog" aria-label={label} className={`absolute z-50 ${position}`}>
|
||||
{children({ close })}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,24 @@
|
||||
export function ProgressBar({value,label}:{value:number;label:string}){const percent=Math.round(Math.min(1,Math.max(0,value))*100);return <div><div className="mb-1 flex justify-between text-[10px] text-text-tertiary"><span>{label}</span><span>{percent}%</span></div><div role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} className="h-1.5 overflow-hidden rounded-full bg-element-active"><div className="h-full rounded-full bg-accent transition-[width]" style={{width:`${percent}%`}}/></div></div>;}
|
||||
export function ProgressBar({ value, label }: { value: number; label: string }) {
|
||||
const percent = Math.round(Math.min(1, Math.max(0, value)) * 100);
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-[10px] text-text-tertiary">
|
||||
<span>{label}</span>
|
||||
<span>{percent}%</span>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
className="h-1.5 overflow-hidden rounded-full bg-element-active"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width]"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,20 @@
|
||||
import type {ReactNode} from 'react';
|
||||
export function PropertyRow({label,value,action}:{label:string;value:ReactNode;action?:ReactNode}){return <div className="grid min-h-6 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 text-xs"><span className="truncate text-text-tertiary">{label}</span><span className="flex min-w-0 items-center justify-end gap-1 text-right text-text-primary"><span className="technical-value truncate">{value}</span>{action}</span></div>;}
|
||||
import type { ReactNode } from 'react';
|
||||
export function PropertyRow({
|
||||
label,
|
||||
value,
|
||||
action,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-h-6 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 text-xs">
|
||||
<span className="truncate text-text-tertiary">{label}</span>
|
||||
<span className="flex min-w-0 items-center justify-end gap-1 text-right text-text-primary">
|
||||
<span className="technical-value truncate">{value}</span>
|
||||
{action}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,127 @@
|
||||
import {useEffect,useRef,useState,type PointerEvent as ReactPointerEvent,type ReactNode} from 'react';
|
||||
const clamp=(value:number,min:number,max:number)=>Math.min(max,Math.max(min,value));
|
||||
const panelMaxWidth=(minWidth:number)=>Math.max(minWidth,Math.min(576,window.innerWidth*.4));
|
||||
function storedWidth(key:string,fallback:number,minWidth:number){try{const value=Number(localStorage.getItem(key));return clamp(Number.isFinite(value)&&value>0?value:fallback,minWidth,panelMaxWidth(minWidth));}catch{return clamp(fallback,minWidth,panelMaxWidth(minWidth));}}
|
||||
export function ResizablePanel({side,storageKey,visible=true,defaultWidth=288,minWidth=224,children,className=''}:{side:'left'|'right';storageKey:string;visible?:boolean;defaultWidth?:number;minWidth?:number;children:ReactNode;className?:string}){const [width,setWidth]=useState(()=>storedWidth(storageKey,defaultWidth,minWidth)),cleanupRef=useRef<()=>void>(()=>{});const update=(next:number)=>{const value=clamp(next,minWidth,panelMaxWidth(minWidth));setWidth(value);try{localStorage.setItem(storageKey,String(value));}catch{/* 无持久化权限时仍可调整 */}};
|
||||
useEffect(()=>{const persist=(next:number)=>{const value=clamp(next,minWidth,panelMaxWidth(minWidth));setWidth(value);try{localStorage.setItem(storageKey,String(value));}catch{/* 忽略 */}},resize=()=>setWidth(value=>clamp(value,minWidth,panelMaxWidth(minWidth))),layout=(event:Event)=>{const widths=(event as CustomEvent<{left:number;right:number}>).detail;persist(widths[side]);};window.addEventListener('resize',resize);window.addEventListener('mujoco-layout-widths',layout);return()=>{window.removeEventListener('resize',resize);window.removeEventListener('mujoco-layout-widths',layout);cleanupRef.current();};},[minWidth,side,storageKey]);
|
||||
const start=(event:ReactPointerEvent<HTMLButtonElement>)=>{event.preventDefault();cleanupRef.current();const origin=event.clientX,startWidth=width,pointerId=event.pointerId;const move=(moveEvent:PointerEvent)=>{if(moveEvent.pointerId===pointerId)update(startWidth+(moveEvent.clientX-origin)*(side==='left'?1:-1));};const stop=(stopEvent:PointerEvent)=>{if(stopEvent.pointerId!==pointerId)return;cleanup();};const cleanup=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',stop);window.removeEventListener('pointercancel',stop);cleanupRef.current=()=>{};};cleanupRef.current=cleanup;window.addEventListener('pointermove',move);window.addEventListener('pointerup',stop);window.addEventListener('pointercancel',stop);};
|
||||
return <div hidden={!visible} className={`relative shrink-0 max-lg:absolute max-lg:inset-y-0 max-lg:z-40 max-lg:shadow-2xl ${side==='left'?'max-lg:left-0':'max-lg:right-0'} ${className}`} style={{width}}>{children}<button type="button" role="separator" aria-label={side==='left'?'调整工程面板宽度':'调整属性面板宽度'} aria-orientation="vertical" aria-valuemin={minWidth} aria-valuemax={Math.round(panelMaxWidth(minWidth))} aria-valuenow={Math.round(width)} onPointerDown={start} onKeyDown={event=>{if(event.key==='Home')update(minWidth);else if(event.key==='End')update(panelMaxWidth(minWidth));else if(event.key==='ArrowLeft')update(width+(side==='left'?-16:16));else if(event.key==='ArrowRight')update(width+(side==='left'?16:-16));else return;event.preventDefault();}} className={`absolute inset-y-0 z-30 w-2 cursor-col-resize bg-transparent outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-accent focus-visible:after:w-0.5 focus-visible:after:bg-accent ${side==='left'?'-right-1':'-left-1'}`}/></div>;}
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
const panelMaxWidth = (minWidth: number) =>
|
||||
Math.max(minWidth, Math.min(576, window.innerWidth * 0.4));
|
||||
function storedWidth(key: string, fallback: number, minWidth: number) {
|
||||
try {
|
||||
const value = Number(localStorage.getItem(key));
|
||||
return clamp(
|
||||
Number.isFinite(value) && value > 0 ? value : fallback,
|
||||
minWidth,
|
||||
panelMaxWidth(minWidth),
|
||||
);
|
||||
} catch {
|
||||
return clamp(fallback, minWidth, panelMaxWidth(minWidth));
|
||||
}
|
||||
}
|
||||
export function ResizablePanel({
|
||||
side,
|
||||
storageKey,
|
||||
visible = true,
|
||||
defaultWidth = 288,
|
||||
minWidth = 224,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
side: 'left' | 'right';
|
||||
storageKey: string;
|
||||
visible?: boolean;
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [width, setWidth] = useState(() => storedWidth(storageKey, defaultWidth, minWidth)),
|
||||
cleanupRef = useRef<() => void>(() => {});
|
||||
const update = (next: number) => {
|
||||
const value = clamp(next, minWidth, panelMaxWidth(minWidth));
|
||||
setWidth(value);
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(value));
|
||||
} catch {
|
||||
/* 无持久化权限时仍可调整 */
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const persist = (next: number) => {
|
||||
const value = clamp(next, minWidth, panelMaxWidth(minWidth));
|
||||
setWidth(value);
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(value));
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
},
|
||||
resize = () => setWidth((value) => clamp(value, minWidth, panelMaxWidth(minWidth))),
|
||||
layout = (event: Event) => {
|
||||
const widths = (event as CustomEvent<{ left: number; right: number }>).detail;
|
||||
persist(widths[side]);
|
||||
};
|
||||
window.addEventListener('resize', resize);
|
||||
window.addEventListener('mujoco-layout-widths', layout);
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('mujoco-layout-widths', layout);
|
||||
cleanupRef.current();
|
||||
};
|
||||
}, [minWidth, side, storageKey]);
|
||||
const start = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
cleanupRef.current();
|
||||
const origin = event.clientX,
|
||||
startWidth = width,
|
||||
pointerId = event.pointerId;
|
||||
const move = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.pointerId === pointerId)
|
||||
update(startWidth + (moveEvent.clientX - origin) * (side === 'left' ? 1 : -1));
|
||||
};
|
||||
const stop = (stopEvent: PointerEvent) => {
|
||||
if (stopEvent.pointerId !== pointerId) return;
|
||||
cleanup();
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
window.removeEventListener('pointercancel', stop);
|
||||
cleanupRef.current = () => {};
|
||||
};
|
||||
cleanupRef.current = cleanup;
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', stop);
|
||||
window.addEventListener('pointercancel', stop);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
hidden={!visible}
|
||||
className={`relative shrink-0 max-lg:absolute max-lg:inset-y-0 max-lg:z-40 max-lg:shadow-2xl ${side === 'left' ? 'max-lg:left-0' : 'max-lg:right-0'} ${className}`}
|
||||
style={{ width }}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
role="separator"
|
||||
aria-label={side === 'left' ? '调整工程面板宽度' : '调整属性面板宽度'}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={minWidth}
|
||||
aria-valuemax={Math.round(panelMaxWidth(minWidth))}
|
||||
aria-valuenow={Math.round(width)}
|
||||
onPointerDown={start}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Home') update(minWidth);
|
||||
else if (event.key === 'End') update(panelMaxWidth(minWidth));
|
||||
else if (event.key === 'ArrowLeft') update(width + (side === 'left' ? -16 : 16));
|
||||
else if (event.key === 'ArrowRight') update(width + (side === 'left' ? 16 : -16));
|
||||
else return;
|
||||
event.preventDefault();
|
||||
}}
|
||||
className={`absolute inset-y-0 z-30 w-2 cursor-col-resize bg-transparent outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-accent focus-visible:after:w-0.5 focus-visible:after:bg-accent ${side === 'left' ? '-right-1' : '-left-1'}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,27 @@
|
||||
export function SearchHighlight({text,query}:{text:string;query:string}){const needle=query.trim().toLocaleLowerCase();if(!needle)return <>{text}</>;const parts:({text:string;match:boolean})[]=[];let start=0,index=text.toLocaleLowerCase().indexOf(needle);while(index>=0){if(index>start)parts.push({text:text.slice(start,index),match:false});parts.push({text:text.slice(index,index+needle.length),match:true});start=index+needle.length;index=text.toLocaleLowerCase().indexOf(needle,start);}if(start<text.length)parts.push({text:text.slice(start),match:false});return <>{parts.map((part,i)=>part.match?<mark key={i} className="rounded-sm bg-warning-soft px-0.5 text-warning">{part.text}</mark>:part.text)}</>;}
|
||||
export function SearchHighlight({ text, query }: { text: string; query: string }) {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
if (!needle) return <>{text}</>;
|
||||
const parts: { text: string; match: boolean }[] = [];
|
||||
let start = 0,
|
||||
index = text.toLocaleLowerCase().indexOf(needle);
|
||||
while (index >= 0) {
|
||||
if (index > start) parts.push({ text: text.slice(start, index), match: false });
|
||||
parts.push({ text: text.slice(index, index + needle.length), match: true });
|
||||
start = index + needle.length;
|
||||
index = text.toLocaleLowerCase().indexOf(needle, start);
|
||||
}
|
||||
if (start < text.length) parts.push({ text: text.slice(start), match: false });
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.match ? (
|
||||
<mark key={i} className="rounded-sm bg-warning-soft px-0.5 text-warning">
|
||||
{part.text}
|
||||
</mark>
|
||||
) : (
|
||||
part.text
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,120 @@
|
||||
import {useId,useMemo,useState} from 'react';
|
||||
import {Check,ChevronsUpDown,Search} from 'lucide-react';
|
||||
import {Popover} from './Popover';
|
||||
export interface ComboboxOption{value:string;label:string;description?:string;}
|
||||
export function SearchableCombobox({options,value,onChange,label,disabled=false}:{options:ComboboxOption[];value?:string;onChange:(value:string)=>void;label:string;disabled?:boolean}){const [query,setQuery]=useState(''),[active,setActive]=useState(0),listId=useId(),selected=options.find(option=>option.value===value),filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return options.filter(option=>!needle||`${option.label} ${option.description??''}`.toLocaleLowerCase().includes(needle));},[options,query]);return <Popover label={label} trigger={({open,toggle})=><button type="button" disabled={disabled} aria-label={label} aria-expanded={open} onClick={toggle} className="flex h-8 w-full items-center justify-between rounded-md border border-border bg-input px-2 text-xs text-text-primary focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40"><span className="truncate">{selected?.label??'请选择'}</span><ChevronsUpDown className="h-3.5 w-3.5 text-text-tertiary"/></button>}>{({close})=><div className="w-72 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl"><div className="relative"><Search className="absolute left-2 top-2 h-3.5 w-3.5 text-text-tertiary"/><input autoFocus role="combobox" aria-label={`搜索${label}`} aria-expanded="true" aria-autocomplete="list" aria-controls={listId} aria-activedescendant={filtered[active]?`${listId}-${active}`:undefined} value={query} onChange={event=>{setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!filtered.length)return;if(event.key==='ArrowDown'){event.preventDefault();setActive(index=>(index+1)%filtered.length);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(index=>(index-1+filtered.length)%filtered.length);}else if(event.key==='Enter'){event.preventDefault();onChange(filtered[active].value);setQuery('');close();}else if(event.key==='Tab')close(false);}} className="h-8 w-full rounded border border-border bg-input pl-7 pr-2 text-xs"/></div><div id={listId} role="listbox" className="panel-scroll mt-1 max-h-64 overflow-auto">{filtered.map((option,index)=><button key={option.value} id={`${listId}-${index}`} role="option" tabIndex={-1} aria-selected={option.value===value} onMouseEnter={()=>setActive(index)} onClick={()=>{onChange(option.value);setQuery('');close();}} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs ${index===active?'bg-accent-soft':''}`}><Check className={`h-3.5 w-3.5 ${option.value===value?'text-accent':'opacity-0'}`}/><span className="min-w-0"><span className="block truncate">{option.label}</span>{option.description&&<span className="block truncate text-[10px] text-text-tertiary">{option.description}</span>}</span></button>)}</div></div>}</Popover>;}
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { Check, ChevronsUpDown, Search } from 'lucide-react';
|
||||
import { Popover } from './Popover';
|
||||
export interface ComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
export function SearchableCombobox({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
disabled = false,
|
||||
}: {
|
||||
options: ComboboxOption[];
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [query, setQuery] = useState(''),
|
||||
[active, setActive] = useState(0),
|
||||
listId = useId(),
|
||||
selected = options.find((option) => option.value === value),
|
||||
filtered = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
return options.filter(
|
||||
(option) =>
|
||||
!needle ||
|
||||
`${option.label} ${option.description ?? ''}`.toLocaleLowerCase().includes(needle),
|
||||
);
|
||||
}, [options, query]);
|
||||
return (
|
||||
<Popover
|
||||
label={label}
|
||||
trigger={({ open, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
className="flex h-8 w-full items-center justify-between rounded-md border border-border bg-input px-2 text-xs text-text-primary focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40"
|
||||
>
|
||||
<span className="truncate">{selected?.label ?? '请选择'}</span>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="w-72 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-text-tertiary" />
|
||||
<input
|
||||
autoFocus
|
||||
role="combobox"
|
||||
aria-label={`搜索${label}`}
|
||||
aria-expanded="true"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={filtered[active] ? `${listId}-${active}` : undefined}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!filtered.length) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActive((index) => (index + 1) % filtered.length);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActive((index) => (index - 1 + filtered.length) % filtered.length);
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onChange(filtered[active].value);
|
||||
setQuery('');
|
||||
close();
|
||||
} else if (event.key === 'Tab') close(false);
|
||||
}}
|
||||
className="h-8 w-full rounded border border-border bg-input pl-7 pr-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div id={listId} role="listbox" className="panel-scroll mt-1 max-h-64 overflow-auto">
|
||||
{filtered.map((option, index) => (
|
||||
<button
|
||||
key={option.value}
|
||||
id={`${listId}-${index}`}
|
||||
role="option"
|
||||
tabIndex={-1}
|
||||
aria-selected={option.value === value}
|
||||
onMouseEnter={() => setActive(index)}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setQuery('');
|
||||
close();
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs ${index === active ? 'bg-accent-soft' : ''}`}
|
||||
>
|
||||
<Check
|
||||
className={`h-3.5 w-3.5 ${option.value === value ? 'text-accent' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="block truncate text-[10px] text-text-tertiary">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,60 @@
|
||||
import {useState} from 'react';
|
||||
import {act,fireEvent,render,screen} from '@testing-library/react';
|
||||
import {Badge,ResizablePanel,Separator,Skeleton,Tabs} from './index';
|
||||
function TabHarness(){const [value,setValue]=useState<'a'|'b'>('a');return <Tabs label="示例" value={value} onValueChange={setValue} items={[{value:'a',label:'甲',content:<span>甲内容</span>},{value:'b',label:'乙',content:<span>乙内容</span>}]}/>;}
|
||||
describe('第二批基础 UI',()=>{
|
||||
it('Tabs 保留面板 DOM并支持方向键导航',()=>{render(<TabHarness/>);const first=screen.getByRole('tab',{name:'甲'});expect(first).toHaveAttribute('tabindex','0');fireEvent.keyDown(first,{key:'ArrowRight'});expect(screen.getByRole('tab',{name:'乙'})).toHaveAttribute('aria-selected','true');expect(screen.getByText('乙内容')).toBeVisible();expect(screen.getByText('甲内容').closest('[role="tabpanel"]')).toHaveAttribute('hidden');});
|
||||
it('ResizablePanel 支持键盘调整、限制异常持久值并保存宽度',()=>{localStorage.setItem('test-width','9999');render(<ResizablePanel side="left" storageKey="test-width"><div>面板</div></ResizablePanel>);const handle=screen.getByRole('separator',{name:'调整工程面板宽度'});expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(Number(handle.getAttribute('aria-valuemax')));fireEvent.keyDown(handle,{key:'Home'});expect(handle).toHaveAttribute('aria-valuenow','224');expect(localStorage.getItem('test-width')).toBe('224');act(()=>window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left:320,right:360}})));expect(handle).toHaveAttribute('aria-valuenow','320');});
|
||||
it('Badge、Separator 和 Skeleton 可渲染',()=>{render(<><Badge>状态</Badge><Separator/><Skeleton className="h-2"/></>);expect(screen.getByText('状态')).toBeVisible();expect(screen.getByRole('separator')).toBeVisible();});
|
||||
import { useState } from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Badge, ResizablePanel, Separator, Skeleton, Tabs } from './index';
|
||||
function TabHarness() {
|
||||
const [value, setValue] = useState<'a' | 'b'>('a');
|
||||
return (
|
||||
<Tabs
|
||||
label="示例"
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
items={[
|
||||
{ value: 'a', label: '甲', content: <span>甲内容</span> },
|
||||
{ value: 'b', label: '乙', content: <span>乙内容</span> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
describe('第二批基础 UI', () => {
|
||||
it('Tabs 保留面板 DOM并支持方向键导航', () => {
|
||||
render(<TabHarness />);
|
||||
const first = screen.getByRole('tab', { name: '甲' });
|
||||
expect(first).toHaveAttribute('tabindex', '0');
|
||||
fireEvent.keyDown(first, { key: 'ArrowRight' });
|
||||
expect(screen.getByRole('tab', { name: '乙' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('乙内容')).toBeVisible();
|
||||
expect(screen.getByText('甲内容').closest('[role="tabpanel"]')).toHaveAttribute('hidden');
|
||||
});
|
||||
it('ResizablePanel 支持键盘调整、限制异常持久值并保存宽度', () => {
|
||||
localStorage.setItem('test-width', '9999');
|
||||
render(
|
||||
<ResizablePanel side="left" storageKey="test-width">
|
||||
<div>面板</div>
|
||||
</ResizablePanel>,
|
||||
);
|
||||
const handle = screen.getByRole('separator', { name: '调整工程面板宽度' });
|
||||
expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(
|
||||
Number(handle.getAttribute('aria-valuemax')),
|
||||
);
|
||||
fireEvent.keyDown(handle, { key: 'Home' });
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '224');
|
||||
expect(localStorage.getItem('test-width')).toBe('224');
|
||||
act(() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mujoco-layout-widths', { detail: { left: 320, right: 360 } }),
|
||||
),
|
||||
);
|
||||
expect(handle).toHaveAttribute('aria-valuenow', '320');
|
||||
});
|
||||
it('Badge、Separator 和 Skeleton 可渲染', () => {
|
||||
render(
|
||||
<>
|
||||
<Badge>状态</Badge>
|
||||
<Separator />
|
||||
<Skeleton className="h-2" />
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByText('状态')).toBeVisible();
|
||||
expect(screen.getByRole('separator')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
import type {SelectHTMLAttributes} from 'react';
|
||||
export function Select({className='',...props}:SelectHTMLAttributes<HTMLSelectElement>){return <select className={`h-7 rounded-md border border-border bg-input px-2 text-xs text-text-primary transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40 ${className}`.trim()} {...props}/>;}
|
||||
import type { SelectHTMLAttributes } from 'react';
|
||||
export function Select({ className = '', ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={`h-7 rounded-md border border-border bg-input px-2 text-xs text-text-primary transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40 ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
export function Separator({orientation='horizontal',className=''}:{orientation?:'horizontal'|'vertical';className?:string}){return <span role="separator" aria-orientation={orientation} className={`${orientation==='horizontal'?'block h-px w-full':'inline-block h-full w-px'} shrink-0 bg-border ${className}`}/>;}
|
||||
export function Separator({
|
||||
orientation = 'horizontal',
|
||||
className = '',
|
||||
}: {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
className={`${orientation === 'horizontal' ? 'block h-px w-full' : 'inline-block h-full w-px'} shrink-0 bg-border ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
export function Skeleton({className=''}:{className?:string}){return <span aria-hidden="true" className={`block animate-pulse rounded bg-element-active ${className}`}/>;}
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`block animate-pulse rounded bg-element-active ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,84 @@
|
||||
import type {KeyboardEvent,ReactNode} from 'react';
|
||||
export interface TabItem<T extends string>{value:T;label:string;icon?:ReactNode;content:ReactNode;disabled?:boolean;}
|
||||
export function Tabs<T extends string>({items,value,onValueChange,label,className='',keepMounted=true}:{items:TabItem<T>[];value:T;onValueChange:(value:T)=>void;label:string;className?:string;keepMounted?:boolean}){const active=items.find(item=>item.value===value)??items.find(item=>!item.disabled)??items[0];const navigate=(event:KeyboardEvent<HTMLButtonElement>)=>{if(!['ArrowLeft','ArrowRight','Home','End'].includes(event.key))return;const enabled=items.filter(item=>!item.disabled);if(!enabled.length)return;const current=enabled.findIndex(item=>item.value===active.value);const next=event.key==='Home'?0:event.key==='End'?enabled.length-1:event.key==='ArrowRight'?(current+1)%enabled.length:(current-1+enabled.length)%enabled.length;event.preventDefault();const item=enabled[next];onValueChange(item.value);requestAnimationFrame(()=>document.getElementById(`${label}-tab-${item.value}`)?.focus());};return <div className={`flex min-h-0 flex-1 flex-col ${className}`}><div role="tablist" aria-label={label} className="flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2">{items.map(item=><button key={item.value} type="button" role="tab" id={`${label}-tab-${item.value}`} tabIndex={item.value===active.value?0:-1} aria-selected={item.value===active.value} aria-controls={`${label}-${item.value}`} disabled={item.disabled} onClick={()=>onValueChange(item.value)} onKeyDown={navigate} className={`relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/40 ${item.value===active.value?'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent':'text-text-tertiary hover:text-text-primary'}`}>{item.icon}{item.label}</button>)}</div>{(keepMounted?items:[active]).map(item=><div key={item.value} id={`${label}-${item.value}`} role="tabpanel" aria-labelledby={`${label}-tab-${item.value}`} hidden={item.value!==active.value} className="panel-scroll min-h-0 flex-1 overflow-auto">{item.content}</div>)}</div>;}
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
export interface TabItem<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
content: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export function Tabs<T extends string>({
|
||||
items,
|
||||
value,
|
||||
onValueChange,
|
||||
label,
|
||||
className = '',
|
||||
keepMounted = true,
|
||||
}: {
|
||||
items: TabItem<T>[];
|
||||
value: T;
|
||||
onValueChange: (value: T) => void;
|
||||
label: string;
|
||||
className?: string;
|
||||
keepMounted?: boolean;
|
||||
}) {
|
||||
const active =
|
||||
items.find((item) => item.value === value) ?? items.find((item) => !item.disabled) ?? items[0];
|
||||
const navigate = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
const enabled = items.filter((item) => !item.disabled);
|
||||
if (!enabled.length) return;
|
||||
const current = enabled.findIndex((item) => item.value === active.value);
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? enabled.length - 1
|
||||
: event.key === 'ArrowRight'
|
||||
? (current + 1) % enabled.length
|
||||
: (current - 1 + enabled.length) % enabled.length;
|
||||
event.preventDefault();
|
||||
const item = enabled[next];
|
||||
onValueChange(item.value);
|
||||
requestAnimationFrame(() => document.getElementById(`${label}-tab-${item.value}`)?.focus());
|
||||
};
|
||||
return (
|
||||
<div className={`flex min-h-0 flex-1 flex-col ${className}`}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className="flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${label}-tab-${item.value}`}
|
||||
tabIndex={item.value === active.value ? 0 : -1}
|
||||
aria-selected={item.value === active.value}
|
||||
aria-controls={`${label}-${item.value}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => onValueChange(item.value)}
|
||||
onKeyDown={navigate}
|
||||
className={`relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/40 ${item.value === active.value ? 'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent' : 'text-text-tertiary hover:text-text-primary'}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(keepMounted ? items : [active]).map((item) => (
|
||||
<div
|
||||
key={item.value}
|
||||
id={`${label}-${item.value}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${label}-tab-${item.value}`}
|
||||
hidden={item.value !== active.value}
|
||||
className="panel-scroll min-h-0 flex-1 overflow-auto"
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
import {fireEvent,render,screen,waitFor} from '@testing-library/react';
|
||||
import {ConfirmDialog,CopyButton,Kbd,PropertyRow,SearchHighlight} from './index';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ConfirmDialog, CopyButton, Kbd, PropertyRow, SearchHighlight } from './index';
|
||||
|
||||
describe('第三批基础 UI',()=>{
|
||||
it('确认弹窗区分取消和危险确认动作',()=>{const confirm=vi.fn(),close=vi.fn();render(<ConfirmDialog open title="移除工程" danger onConfirm={confirm} onClose={close}>确认内容</ConfirmDialog>);fireEvent.click(screen.getByRole('button',{name:'确认'}));expect(confirm).toHaveBeenCalledTimes(1);fireEvent.click(screen.getByRole('button',{name:'取消'}));expect(close).toHaveBeenCalledTimes(1);});
|
||||
it('属性行支持复制且搜索词可高亮',async()=>{const writeText=vi.fn().mockResolvedValue(undefined);Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText}});render(<><PropertyRow label="Body" value="robot" action={<CopyButton value="robot"/>}/><SearchHighlight text="robot_arm" query="arm"/><Kbd>Ctrl+K</Kbd></>);fireEvent.click(screen.getByRole('button',{name:'复制'}));await waitFor(()=>expect(writeText).toHaveBeenCalledWith('robot'));expect(screen.getByText('arm').tagName).toBe('MARK');expect(screen.getByText('Ctrl+K')).toBeVisible();});
|
||||
it('Clipboard API 不可用时复制按钮不会抛错',()=>{Object.defineProperty(navigator,'clipboard',{configurable:true,value:undefined});render(<CopyButton value="robot"/>);expect(()=>fireEvent.click(screen.getByRole('button',{name:'复制'}))).not.toThrow();});
|
||||
describe('第三批基础 UI', () => {
|
||||
it('确认弹窗区分取消和危险确认动作', () => {
|
||||
const confirm = vi.fn(),
|
||||
close = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog open title="移除工程" danger onConfirm={confirm} onClose={close}>
|
||||
确认内容
|
||||
</ConfirmDialog>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('属性行支持复制且搜索词可高亮', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
|
||||
render(
|
||||
<>
|
||||
<PropertyRow label="Body" value="robot" action={<CopyButton value="robot" />} />
|
||||
<SearchHighlight text="robot_arm" query="arm" />
|
||||
<Kbd>Ctrl+K</Kbd>
|
||||
</>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('robot'));
|
||||
expect(screen.getByText('arm').tagName).toBe('MARK');
|
||||
expect(screen.getByText('Ctrl+K')).toBeVisible();
|
||||
});
|
||||
it('Clipboard API 不可用时复制按钮不会抛错', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
|
||||
render(<CopyButton value="robot" />);
|
||||
expect(() => fireEvent.click(screen.getByRole('button', { name: '复制' }))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
import type {ComponentType} from 'react';
|
||||
import {IconButton} from './IconButton';
|
||||
export interface ToolbarItem<T extends string>{value:T;label:string;icon:ComponentType<{className?:string}>;}
|
||||
export function ToolbarToggleGroup<T extends string>({items,value,onChange,label}:{items:readonly ToolbarItem<T>[];value:T;onChange:(value:T)=>void;label:string}){
|
||||
return <div role="toolbar" aria-label={label} className="flex items-center gap-0.5 rounded-lg border border-border bg-surface/80 p-0.5 shadow-sm">{items.map(item=>{const Icon=item.icon;return <IconButton key={item.value} active={item.value===value} tooltip={item.label} aria-label={item.label} onClick={()=>onChange(item.value)}><Icon className="h-3.5 w-3.5"/></IconButton>;})}</div>;
|
||||
import type { ComponentType } from 'react';
|
||||
import { IconButton } from './IconButton';
|
||||
export interface ToolbarItem<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
export function ToolbarToggleGroup<T extends string>({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
items: readonly ToolbarItem<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={label}
|
||||
className="flex items-center gap-0.5 rounded-lg border border-border bg-surface/80 p-0.5 shadow-sm"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<IconButton
|
||||
key={item.value}
|
||||
active={item.value === value}
|
||||
tooltip={item.label}
|
||||
aria-label={item.label}
|
||||
onClick={() => onChange(item.value)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import type {ReactElement,ReactNode} from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
export function Tooltip({content,children,side='bottom'}:{content:ReactNode;children:ReactElement;side?:'top'|'bottom'}){
|
||||
if(!content)return children;
|
||||
return <span className="group/tooltip relative inline-flex">
|
||||
{children}
|
||||
<span role="tooltip" className={`pointer-events-none absolute left-1/2 z-[500] hidden w-max max-w-64 -translate-x-1/2 rounded-md border border-border bg-surface-elevated px-2 py-1 text-[10px] font-medium text-text-primary shadow-lg group-hover/tooltip:block group-focus-within/tooltip:block ${side==='top'?'bottom-full mb-1.5':'top-full mt-1.5'}`}>{content}</span>
|
||||
</span>;
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
side = 'bottom',
|
||||
}: {
|
||||
content: ReactNode;
|
||||
children: ReactElement;
|
||||
side?: 'top' | 'bottom';
|
||||
}) {
|
||||
if (!content) return children;
|
||||
return (
|
||||
<span className="group/tooltip relative inline-flex">
|
||||
{children}
|
||||
<span
|
||||
role="tooltip"
|
||||
className={`pointer-events-none absolute left-1/2 z-[500] hidden w-max max-w-64 -translate-x-1/2 rounded-md border border-border bg-surface-elevated px-2 py-1 text-[10px] font-medium text-text-primary shadow-lg group-hover/tooltip:block group-focus-within/tooltip:block ${side === 'top' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'}`}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {VirtualTreeViewport} from './VirtualTreeViewport';
|
||||
describe('VirtualTreeViewport',()=>{it('只渲染可视窗口并在滚动后更新行',()=>{const items=Array.from({length:1000},(_,id)=>({id,label:`节点 ${id}`}));render(<VirtualTreeViewport label="大型树" items={items} height={100} rowHeight={20} overscan={1} getKey={item=>item.id} renderRow={item=><div>{item.label}</div>}/>);const tree=screen.getByRole('tree',{name:'大型树'});expect(screen.getByText('节点 0')).toBeVisible();expect(screen.queryByText('节点 500')).not.toBeInTheDocument();Object.defineProperty(tree,'scrollTop',{configurable:true,value:10000});fireEvent.scroll(tree);expect(screen.getByText('节点 500')).toBeVisible();fireEvent.keyDown(tree,{key:'ArrowDown'});expect(tree.getAttribute('aria-activedescendant')).toContain('501');});});
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { VirtualTreeViewport } from './VirtualTreeViewport';
|
||||
describe('VirtualTreeViewport', () => {
|
||||
it('只渲染可视窗口并在滚动后更新行', () => {
|
||||
const items = Array.from({ length: 1000 }, (_, id) => ({ id, label: `节点 ${id}` }));
|
||||
render(
|
||||
<VirtualTreeViewport
|
||||
label="大型树"
|
||||
items={items}
|
||||
height={100}
|
||||
rowHeight={20}
|
||||
overscan={1}
|
||||
getKey={(item) => item.id}
|
||||
renderRow={(item) => <div>{item.label}</div>}
|
||||
/>,
|
||||
);
|
||||
const tree = screen.getByRole('tree', { name: '大型树' });
|
||||
expect(screen.getByText('节点 0')).toBeVisible();
|
||||
expect(screen.queryByText('节点 500')).not.toBeInTheDocument();
|
||||
Object.defineProperty(tree, 'scrollTop', { configurable: true, value: 10000 });
|
||||
fireEvent.scroll(tree);
|
||||
expect(screen.getByText('节点 500')).toBeVisible();
|
||||
fireEvent.keyDown(tree, { key: 'ArrowDown' });
|
||||
expect(tree.getAttribute('aria-activedescendant')).toContain('501');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,2 +1,122 @@
|
||||
import {useMemo,useRef,useState,type KeyboardEvent,type ReactNode} from 'react';
|
||||
export function VirtualTreeViewport<T>({items,rowHeight=26,height=520,overscan=6,getKey,getLevel=()=>1,isExpandable=()=>false,isExpanded=()=>false,onToggle,onActiveChange,renderRow,label}:{items:T[];rowHeight?:number;height?:number;overscan?:number;getKey:(item:T)=>string|number;getLevel?:(item:T)=>number;isExpandable?:(item:T)=>boolean;isExpanded?:(item:T)=>boolean;onToggle?:(item:T)=>void;onActiveChange?:(item:T)=>void;renderRow:(item:T,index:number)=>ReactNode;label:string}){const root=useRef<HTMLDivElement>(null),[scrollTop,setScrollTop]=useState(0),[active,setActive]=useState(0),range=useMemo(()=>{const start=Math.max(0,Math.floor(scrollTop/rowHeight)-overscan),count=Math.ceil(height/rowHeight)+overscan*2;return {start,end:Math.min(items.length,start+count)};},[height,items.length,overscan,rowHeight,scrollTop]),safeActive=Math.min(active,Math.max(0,items.length-1)),activeId=items.length?`${label}-${getKey(items[safeActive])}`:undefined;const activate=(index:number)=>{const next=Math.min(items.length-1,Math.max(0,index));setActive(next);const item=items[next];if(item)onActiveChange?.(item);const viewport=root.current;if(viewport){const top=next*rowHeight;if(top<viewport.scrollTop)viewport.scrollTop=top;else if(top+rowHeight>viewport.scrollTop+height)viewport.scrollTop=top+rowHeight-height;}};const key=(event:KeyboardEvent<HTMLDivElement>)=>{if(!items.length)return;const item=items[safeActive],level=getLevel(item);if(event.key==='ArrowDown')activate(safeActive+1);else if(event.key==='ArrowUp')activate(safeActive-1);else if(event.key==='Home')activate(0);else if(event.key==='End')activate(items.length-1);else if(event.key==='ArrowRight'&&isExpandable(item)&&!isExpanded(item))onToggle?.(item);else if(event.key==='ArrowLeft'&&isExpandable(item)&&isExpanded(item))onToggle?.(item);else if(event.key==='ArrowLeft'){for(let index=safeActive-1;index>=0;index--)if(getLevel(items[index])<level){activate(index);break;}}else if((event.key==='Enter'||event.key===' ')&&isExpandable(item))onToggle?.(item);else return;event.preventDefault();};return <div ref={root} role="tree" aria-label={label} aria-activedescendant={activeId} tabIndex={0} className="panel-scroll relative overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30" style={{height:Math.min(height,Math.max(rowHeight,items.length*rowHeight))}} onKeyDown={key} onScroll={event=>{const top=event.currentTarget.scrollTop,first=Math.floor(top/rowHeight),last=first+Math.ceil(height/rowHeight);setScrollTop(top);if(safeActive<first||safeActive>last){setActive(first);if(items[first])onActiveChange?.(items[first]);}}}><div style={{height:items.length*rowHeight,position:'relative'}}>{items.slice(range.start,range.end).map((item,offset)=>{const index=range.start+offset,expandable=isExpandable(item);return <div id={`${label}-${getKey(item)}`} role="treeitem" aria-level={getLevel(item)} aria-expanded={expandable?isExpanded(item):undefined} key={getKey(item)} onMouseDown={()=>activate(index)} className={index===safeActive?'bg-accent-soft/60':''} style={{position:'absolute',left:0,right:0,top:index*rowHeight,height:rowHeight}}>{renderRow(item,index)}</div>;})}</div></div>;}
|
||||
import { useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||
export function VirtualTreeViewport<T>({
|
||||
items,
|
||||
rowHeight = 26,
|
||||
height = 520,
|
||||
overscan = 6,
|
||||
getKey,
|
||||
getLevel = () => 1,
|
||||
isExpandable = () => false,
|
||||
isExpanded = () => false,
|
||||
onToggle,
|
||||
onActiveChange,
|
||||
renderRow,
|
||||
label,
|
||||
}: {
|
||||
items: T[];
|
||||
rowHeight?: number;
|
||||
height?: number;
|
||||
overscan?: number;
|
||||
getKey: (item: T) => string | number;
|
||||
getLevel?: (item: T) => number;
|
||||
isExpandable?: (item: T) => boolean;
|
||||
isExpanded?: (item: T) => boolean;
|
||||
onToggle?: (item: T) => void;
|
||||
onActiveChange?: (item: T) => void;
|
||||
renderRow: (item: T, index: number) => ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null),
|
||||
[scrollTop, setScrollTop] = useState(0),
|
||||
[active, setActive] = useState(0),
|
||||
range = useMemo(() => {
|
||||
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan),
|
||||
count = Math.ceil(height / rowHeight) + overscan * 2;
|
||||
return { start, end: Math.min(items.length, start + count) };
|
||||
}, [height, items.length, overscan, rowHeight, scrollTop]),
|
||||
safeActive = Math.min(active, Math.max(0, items.length - 1)),
|
||||
activeId = items.length ? `${label}-${getKey(items[safeActive])}` : undefined;
|
||||
const activate = (index: number) => {
|
||||
const next = Math.min(items.length - 1, Math.max(0, index));
|
||||
setActive(next);
|
||||
const item = items[next];
|
||||
if (item) onActiveChange?.(item);
|
||||
const viewport = root.current;
|
||||
if (viewport) {
|
||||
const top = next * rowHeight;
|
||||
if (top < viewport.scrollTop) viewport.scrollTop = top;
|
||||
else if (top + rowHeight > viewport.scrollTop + height)
|
||||
viewport.scrollTop = top + rowHeight - height;
|
||||
}
|
||||
};
|
||||
const key = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!items.length) return;
|
||||
const item = items[safeActive],
|
||||
level = getLevel(item);
|
||||
if (event.key === 'ArrowDown') activate(safeActive + 1);
|
||||
else if (event.key === 'ArrowUp') activate(safeActive - 1);
|
||||
else if (event.key === 'Home') activate(0);
|
||||
else if (event.key === 'End') activate(items.length - 1);
|
||||
else if (event.key === 'ArrowRight' && isExpandable(item) && !isExpanded(item))
|
||||
onToggle?.(item);
|
||||
else if (event.key === 'ArrowLeft' && isExpandable(item) && isExpanded(item)) onToggle?.(item);
|
||||
else if (event.key === 'ArrowLeft') {
|
||||
for (let index = safeActive - 1; index >= 0; index--)
|
||||
if (getLevel(items[index]) < level) {
|
||||
activate(index);
|
||||
break;
|
||||
}
|
||||
} else if ((event.key === 'Enter' || event.key === ' ') && isExpandable(item)) onToggle?.(item);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
role="tree"
|
||||
aria-label={label}
|
||||
aria-activedescendant={activeId}
|
||||
tabIndex={0}
|
||||
className="panel-scroll relative overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30"
|
||||
style={{ height: Math.min(height, Math.max(rowHeight, items.length * rowHeight)) }}
|
||||
onKeyDown={key}
|
||||
onScroll={(event) => {
|
||||
const top = event.currentTarget.scrollTop,
|
||||
first = Math.floor(top / rowHeight),
|
||||
last = first + Math.ceil(height / rowHeight);
|
||||
setScrollTop(top);
|
||||
if (safeActive < first || safeActive > last) {
|
||||
setActive(first);
|
||||
if (items[first]) onActiveChange?.(items[first]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ height: items.length * rowHeight, position: 'relative' }}>
|
||||
{items.slice(range.start, range.end).map((item, offset) => {
|
||||
const index = range.start + offset,
|
||||
expandable = isExpandable(item);
|
||||
return (
|
||||
<div
|
||||
id={`${label}-${getKey(item)}`}
|
||||
role="treeitem"
|
||||
aria-level={getLevel(item)}
|
||||
aria-expanded={expandable ? isExpanded(item) : undefined}
|
||||
key={getKey(item)}
|
||||
onMouseDown={() => activate(index)}
|
||||
className={index === safeActive ? 'bg-accent-soft/60' : ''}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: index * rowHeight,
|
||||
height: rowHeight,
|
||||
}}
|
||||
>
|
||||
{renderRow(item, index)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user