Compare commits

..

2 Commits

Author SHA1 Message Date
yyh
373d35c4bc test(web): update preview container semantics 2026-06-18 21:47:19 +08:00
yyh
d2291417fa feat(web): add app shell skip navigation 2026-06-18 21:37:00 +08:00
32 changed files with 140 additions and 33 deletions

View File

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PreviewContainer from '../container'
// Tests for PreviewContainer - a layout wrapper with header and scrollable main area
// Tests for PreviewContainer - a layout wrapper with header and scrollable content area
describe('PreviewContainer', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -17,11 +17,11 @@ describe('PreviewContainer', () => {
expect(headerEl)!.toBeInTheDocument()
})
it('should render children in a main element', () => {
render(<PreviewContainer header="Header">Main content</PreviewContainer>)
it('should render children in the content area', () => {
render(<PreviewContainer header="Header" data-testid="inner-container">Main content</PreviewContainer>)
const mainEl = screen.getByRole('main')
expect(mainEl)!.toHaveTextContent('Main content')
const contentEl = screen.getByTestId('inner-container').lastElementChild
expect(contentEl)!.toHaveTextContent('Main content')
})
it('should render both header and children simultaneously', () => {
@ -36,10 +36,11 @@ describe('PreviewContainer', () => {
})
it('should render without children', () => {
render(<PreviewContainer header="Header" />)
render(<PreviewContainer header="Header" data-testid="inner-container" />)
expect(screen.getByRole('main'))!.toBeInTheDocument()
expect(screen.getByRole('main').childElementCount).toBe(0)
const contentEl = screen.getByTestId('inner-container').lastElementChild
expect(contentEl)!.toBeInTheDocument()
expect(contentEl!.childElementCount).toBe(0)
})
})
@ -52,16 +53,14 @@ describe('PreviewContainer', () => {
expect(container.firstElementChild)!.toHaveClass('outer-class')
})
it('should apply mainClassName to the main element', () => {
it('should apply mainClassName to the content area', () => {
render(
<PreviewContainer header="Header" mainClassName="custom-main">Content</PreviewContainer>,
<PreviewContainer header="Header" mainClassName="custom-main" data-testid="inner-container">Content</PreviewContainer>,
)
const mainEl = screen.getByRole('main')
expect(mainEl)!.toHaveClass('custom-main')
// Default classes should still be present
// Default classes should still be present
expect(mainEl)!.toHaveClass('w-full', 'grow', 'overflow-y-auto', 'px-6', 'py-5')
const contentEl = screen.getByTestId('inner-container').lastElementChild
expect(contentEl)!.toHaveClass('custom-main')
expect(contentEl)!.toHaveClass('w-full', 'grow', 'overflow-y-auto', 'px-6', 'py-5')
})
it('should forward ref to the inner container div', () => {
@ -116,10 +115,10 @@ describe('PreviewContainer', () => {
expect(inner)!.toHaveClass('flex', 'h-full', 'w-full', 'flex-col')
})
it('should have main with overflow-y-auto for scrolling', () => {
render(<PreviewContainer header="Header">Content</PreviewContainer>)
it('should have content area with overflow-y-auto for scrolling', () => {
render(<PreviewContainer header="Header" data-testid="inner-container">Content</PreviewContainer>)
expect(screen.getByRole('main'))!.toHaveClass('overflow-y-auto')
expect(screen.getByTestId('inner-container').lastElementChild)!.toHaveClass('overflow-y-auto')
})
})
@ -139,9 +138,9 @@ describe('PreviewContainer', () => {
})
it('should render with null children', () => {
render(<PreviewContainer header="Header">{null}</PreviewContainer>)
render(<PreviewContainer header="Header" data-testid="inner-container">{null}</PreviewContainer>)
expect(screen.getByRole('main'))!.toBeInTheDocument()
expect(screen.getByTestId('inner-container').lastElementChild)!.toBeInTheDocument()
})
it('should render with multiple children', () => {

View File

@ -19,9 +19,9 @@ const PreviewContainer: FC<PreviewContainerProps> = (props) => {
<header className="border-b border-divider-subtle pt-4 pr-4 pb-3 pl-5">
{header}
</header>
<main className={cn('w-full grow overflow-y-auto px-6 py-5', mainClassName)}>
<div className={cn('w-full grow overflow-y-auto px-6 py-5', mainClassName)}>
{children}
</main>
</div>
</div>
</div>
)

View File

@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import MainNavLayout from '../layout'
vi.mock('@/app/components/header', () => ({
@ -10,6 +10,12 @@ vi.mock('@/app/components/header/header-wrapper', () => ({
default: ({ children }: { children: ReactNode }) => <div data-testid="header-wrapper">{children}</div>,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../index', () => ({
default: ({ className }: { className?: string }) => <aside className={className} data-testid="main-nav">MainNav</aside>,
}))
@ -34,4 +40,37 @@ describe('MainNavLayout', () => {
expect(screen.queryByTestId('header-wrapper')).not.toBeInTheDocument()
expect(screen.queryByTestId('desktop-header')).not.toBeInTheDocument()
})
it('renders one main landmark as the skip navigation target', () => {
render(<MainNavLayout><div>content</div></MainNavLayout>)
const main = screen.getByRole('main')
expect(screen.getAllByRole('main')).toHaveLength(1)
expect(main).toHaveAttribute('id', 'main-content')
expect(main).toHaveAttribute('tabIndex', '-1')
expect(main).toHaveClass('outline-hidden', 'focus:outline-hidden', 'focus-visible:outline-hidden')
expect(main).toHaveTextContent('content')
})
it('renders skip navigation before the repeated main navigation', () => {
const { container } = render(<MainNavLayout><div>content</div></MainNavLayout>)
const skipLink = screen.getByRole('link', { name: 'navigation.skipToMain' })
expect(skipLink).toHaveAttribute('href', '#main-content')
expect(skipLink).toHaveClass('outline-hidden', 'focus-visible:ring-2', 'focus-visible:ring-state-accent-solid')
expect(container.querySelector('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])')).toBe(skipLink)
})
it('moves focus to the main content when skip navigation is activated', () => {
render(<MainNavLayout><div>content</div></MainNavLayout>)
const skipLink = screen.getByRole('link', { name: 'navigation.skipToMain' })
const main = screen.getByRole('main')
fireEvent.click(skipLink)
expect(main).toHaveFocus()
})
})

View File

@ -1,7 +1,9 @@
'use client'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import MainNav from './index'
import { MAIN_CONTENT_ID, SkipNav } from './skip-nav'
type MainNavLayoutProps = {
children: ReactNode
@ -10,12 +12,19 @@ type MainNavLayoutProps = {
const MainNavLayout = ({
children,
}: MainNavLayoutProps) => {
const { t } = useTranslation('common')
return (
<div className="flex h-0 min-h-0 grow overflow-hidden bg-background-body">
<SkipNav>{t('navigation.skipToMain')}</SkipNav>
<MainNav />
<div className="flex min-w-0 grow flex-col overflow-hidden">
<main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex min-w-0 grow flex-col overflow-hidden outline-hidden focus:outline-hidden focus-visible:outline-hidden"
>
{children}
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,37 @@
'use client'
import type { ComponentProps, MouseEventHandler } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
export const MAIN_CONTENT_ID = 'main-content'
const MAIN_CONTENT_HREF = `#${MAIN_CONTENT_ID}`
export function SkipNav({
className,
children,
onClick,
...props
}: ComponentProps<'a'>) {
const handleClick: MouseEventHandler<HTMLAnchorElement> = (event) => {
onClick?.(event)
if (event.defaultPrevented)
return
document.getElementById(MAIN_CONTENT_ID)?.focus()
}
return (
<a
href={MAIN_CONTENT_HREF}
onClick={handleClick}
className={cn(
'fixed top-2 left-2 z-60 inline-flex h-9 -translate-y-[calc(100%+0.75rem)] items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text shadow-lg shadow-shadow-shadow-5 outline-hidden transition-transform duration-150 focus-visible:translate-y-0 focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none',
className,
)}
{...props}
>
{children}
</a>
)
}

View File

@ -236,7 +236,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
<SearchInput placeholder={t('nodes.agent.strategy.searchPlaceholder', { ns: 'workflow' })} value={query} onValueChange={setQuery} className="w-full" />
<ViewTypeSelect viewType={viewType} onChange={setViewType} />
</header>
<main className="relative flex w-full flex-col overflow-hidden md:max-h-[300px] xl:max-h-[400px] 2xl:max-h-[564px]" ref={wrapElemRef}>
<div className="relative flex w-full flex-col overflow-hidden md:max-h-[300px] xl:max-h-[400px] 2xl:max-h-[564px]" ref={wrapElemRef}>
<Tools
tools={filteredTools}
viewType={viewType}
@ -268,7 +268,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
disableMaxWidth
/>
)}
</main>
</div>
</div>
</PopoverContent>
</Popover>

View File

@ -27,10 +27,10 @@ export function AgentDetailLayout({
useDocumentTitle(agentQuery.data?.name ?? t('agentDetail.documentTitle'))
return (
<main className="relative flex h-full min-w-0 flex-col overflow-hidden">
<div className="relative flex h-full min-w-0 flex-col overflow-hidden">
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
{children}
</div>
</main>
</div>
)
}

View File

@ -84,7 +84,7 @@ export default function RosterPage() {
useDocumentTitle(tCommon('menus.roster'))
return (
<main className="flex h-0 min-w-0 grow flex-col overflow-hidden bg-background-body">
<div className="flex h-0 min-w-0 grow flex-col overflow-hidden bg-background-body">
<Tabs defaultValue="agent" className="flex min-h-0 flex-1 flex-col">
<div className="h-25.5 shrink-0 bg-background-body px-8 pt-4 pb-4">
<div className="flex min-w-0 items-center justify-between gap-4">
@ -145,6 +145,6 @@ export default function RosterPage() {
</ScrollAreaRoot>
</TabsPanel>
</Tabs>
</main>
</div>
)
}

View File

@ -17,7 +17,7 @@ export function CreateDeploymentGuide() {
backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]"
className="top-4 bottom-4 h-auto max-h-none w-[min(calc(100vw-2rem),1120px)] max-w-none translate-y-0 overflow-hidden border-effects-highlight bg-background-default-subtle p-0"
>
<main className="relative flex h-full min-w-0 grow flex-col overflow-hidden">
<div className="relative flex h-full min-w-0 grow flex-col overflow-hidden">
<Link
href="/deployments"
aria-label={t('createGuide.nav.back')}
@ -28,7 +28,7 @@ export function CreateDeploymentGuide() {
<CreateDeploymentGuideProvider>
<CreateDeploymentGuideShell />
</CreateDeploymentGuideProvider>
</main>
</div>
</DialogContent>
</Dialog>
)

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "نموذج تحويل النص إلى كلام",
"modelProvider.ttsModel.tip": "تعيين النموذج الافتراضي لإدخال تحويل النص إلى كلام في المحادثة.",
"modelProvider.upgradeForLoadBalancing": "قم بترقية خطتك لتمكين موازنة التحميل.",
"navigation.skipToMain": "الانتقال إلى المحتوى الرئيسي",
"noData": "لا توجد بيانات",
"operation.add": "إضافة",
"operation.added": "تمت الإضافة",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Text-zu-Sprache-Modell",
"modelProvider.ttsModel.tip": "Legen Sie das Standardmodell für die Text-zu-Sprache-Eingabe in Konversationen fest.",
"modelProvider.upgradeForLoadBalancing": "Aktualisieren Sie Ihren Plan, um den Lastenausgleich zu aktivieren.",
"navigation.skipToMain": "Zum Hauptinhalt springen",
"noData": "Keine Daten",
"operation.add": "Hinzufügen",
"operation.added": "Hinzugefügt",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Text-to-Speech Model",
"modelProvider.ttsModel.tip": "Set the default model for text-to-speech input in conversation.",
"modelProvider.upgradeForLoadBalancing": "Upgrade your plan to enable Load Balancing.",
"navigation.skipToMain": "Skip to main content",
"noData": "No data",
"operation.add": "Add",
"operation.added": "Added",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Modelo de Texto a Voz",
"modelProvider.ttsModel.tip": "Establece el modelo predeterminado para la entrada de texto a voz en la conversación.",
"modelProvider.upgradeForLoadBalancing": "Actualiza tu plan para habilitar el Balanceo de Carga.",
"navigation.skipToMain": "Saltar al contenido principal",
"noData": "Sin datos",
"operation.add": "Agregar",
"operation.added": "Agregado",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "مدل تبدیل متن به گفتار",
"modelProvider.ttsModel.tip": "مدل پیش‌فرض را برای ورودی متن به گفتار در مکالمه تنظیم کنید.",
"modelProvider.upgradeForLoadBalancing": "برای فعال کردن تعادل بار، طرح خود را ارتقا دهید.",
"navigation.skipToMain": "پرش به محتوای اصلی",
"noData": "بدون داده",
"operation.add": "افزودن",
"operation.added": "اضافه شد",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Modèle de Texte-à-Parole",
"modelProvider.ttsModel.tip": "Définissez le modèle par défaut pour l'entrée de texte à la parole dans une conversation.",
"modelProvider.upgradeForLoadBalancing": "Mettez à niveau votre plan pour activer léquilibrage de charge.",
"navigation.skipToMain": "Aller au contenu principal",
"noData": "Aucune donnée",
"operation.add": "Ajouter",
"operation.added": "Ajouté",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "पाठ-से-भाषण मॉडल",
"modelProvider.ttsModel.tip": "संवाद में पाठ-से-भाषण इनपुट के लिए डिफ़ॉल्ट मॉडल सेट करें।",
"modelProvider.upgradeForLoadBalancing": "लोड बैलेंसिंग सक्षम करने के लिए अपनी योजना अपग्रेड करें।",
"navigation.skipToMain": "मुख्य सामग्री पर जाएं",
"noData": "कोई डेटा नहीं",
"operation.add": "जोड़ें",
"operation.added": "जोड़ा गया",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Model Teks-ke-Ucapan",
"modelProvider.ttsModel.tip": "Atur model default untuk input teks-ke-ucapan dalam percakapan.",
"modelProvider.upgradeForLoadBalancing": "Tingkatkan paket Anda untuk mengaktifkan Penyeimbangan Beban.",
"navigation.skipToMain": "Lewati ke konten utama",
"noData": "Tidak ada data",
"operation.add": "Tambah",
"operation.added": "Ditambahkan",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Modello da Testo a Voce",
"modelProvider.ttsModel.tip": "Imposta il modello predefinito per l'input da testo a voce nella conversazione.",
"modelProvider.upgradeForLoadBalancing": "Aggiorna il tuo piano per abilitare il Bilanciamento del Carico.",
"navigation.skipToMain": "Vai al contenuto principale",
"noData": "Nessun dato",
"operation.add": "Aggiungi",
"operation.added": "Aggiunto",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "テキスト-to-音声モデル",
"modelProvider.ttsModel.tip": "会話でのテキスト-to-音声入力に使用するデフォルトモデルを設定します。",
"modelProvider.upgradeForLoadBalancing": "負荷分散を利用するには、プランのアップグレードが必要です。",
"navigation.skipToMain": "メインコンテンツへスキップ",
"noData": "データなし",
"operation.add": "追加",
"operation.added": "追加済み",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "텍스트-to-음성 모델",
"modelProvider.ttsModel.tip": "대화에서의 텍스트-to-음성 입력에 사용되는 기본 모델을 설정합니다.",
"modelProvider.upgradeForLoadBalancing": "로드 밸런싱을 사용하도록 계획을 업그레이드합니다.",
"navigation.skipToMain": "본문으로 건너뛰기",
"noData": "데이터 없음",
"operation.add": "추가",
"operation.added": "추가됨",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Text-to-Speech Model",
"modelProvider.ttsModel.tip": "Set the default model for text-to-speech input in conversation.",
"modelProvider.upgradeForLoadBalancing": "Upgrade your plan to enable Load Balancing.",
"navigation.skipToMain": "Naar hoofdinhoud springen",
"noData": "No data",
"operation.add": "Add",
"operation.added": "Added",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Model tekstu na mowę",
"modelProvider.ttsModel.tip": "Ustaw domyślny model dla konwersji tekstu na mowę w rozmowach.",
"modelProvider.upgradeForLoadBalancing": "Uaktualnij swój plan, aby włączyć równoważenie obciążenia.",
"navigation.skipToMain": "Przejdź do głównej treści",
"noData": "Brak danych",
"operation.add": "Dodaj",
"operation.added": "Dodano",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Modelo de Texto para Fala",
"modelProvider.ttsModel.tip": "Defina o modelo padrão para entrada de texto para fala na conversa.",
"modelProvider.upgradeForLoadBalancing": "Atualize seu plano para habilitar o balanceamento de carga.",
"navigation.skipToMain": "Pular para o conteúdo principal",
"noData": "Sem dados",
"operation.add": "Adicionar",
"operation.added": "Adicionado",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Model de conversie vorbire-la-text",
"modelProvider.ttsModel.tip": "Setați modelul implicit pentru intrarea de conversie vorbire-la-text în conversație.",
"modelProvider.upgradeForLoadBalancing": "Actualizați-vă planul pentru a activa Load Balancing.",
"navigation.skipToMain": "Sări la conținutul principal",
"noData": "Fără date",
"operation.add": "Adaugă",
"operation.added": "Adăugat",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Модель преобразования текста в речь",
"modelProvider.ttsModel.tip": "Установите модель по умолчанию для ввода текста в речь в разговоре.",
"modelProvider.upgradeForLoadBalancing": "Обновите свой тарифный план, чтобы включить балансировку нагрузки.",
"navigation.skipToMain": "Перейти к основному содержанию",
"noData": "Нет данных",
"operation.add": "Добавить",
"operation.added": "Добавлено",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Model za pretvorbo besedila v govor",
"modelProvider.ttsModel.tip": "Nastavite privzeti model za pretvorbo besedila v govor v pogovoru.",
"modelProvider.upgradeForLoadBalancing": "Nadgradite svoj načrt, da omogočite uravnoteženje obremenitev.",
"navigation.skipToMain": "Preskoči na glavno vsebino",
"noData": "Ni podatkov",
"operation.add": "Dodaj",
"operation.added": "Dodano",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "โมเดลการแปลงข้อความเป็นคําพูด",
"modelProvider.ttsModel.tip": "ตั้งค่าโมเดลเริ่มต้นสําหรับการป้อนข้อมูลเป็นข้อความเป็นคําพูดในการสนทนา",
"modelProvider.upgradeForLoadBalancing": "อัปเกรดแผนของคุณเพื่อเปิดใช้งานการปรับสมดุลโหลด",
"navigation.skipToMain": "ข้ามไปยังเนื้อหาหลัก",
"noData": "ไม่มีข้อมูล",
"operation.add": "เพิ่ม",
"operation.added": "เพิ่ม",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Metinden Konuşmaya Modeli",
"modelProvider.ttsModel.tip": "Konuşmada metinden konuşmaya giriş için varsayılan modeli ayarlayın.",
"modelProvider.upgradeForLoadBalancing": "Yük Dengelemeyi etkinleştirmek için planınızı yükseltin.",
"navigation.skipToMain": "Ana içeriğe geç",
"noData": "Veri yok",
"operation.add": "Ekle",
"operation.added": "Eklendi",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Модель перетворення тексту в мовлення",
"modelProvider.ttsModel.tip": "Встановіть модель за замовчуванням для введення тексту в мовлення в розмові.",
"modelProvider.upgradeForLoadBalancing": "Оновіть свій план, щоб увімкнути балансування навантаження.",
"navigation.skipToMain": "Перейти до основного вмісту",
"noData": "Немає даних",
"operation.add": "Додати",
"operation.added": "Додано",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "Mô hình Văn bản thành Tiếng nói",
"modelProvider.ttsModel.tip": "Thiết lập mô hình mặc định cho đầu vào văn bản thành tiếng nói trong cuộc trò chuyện.",
"modelProvider.upgradeForLoadBalancing": "Nâng cấp gói của bạn để bật Cân bằng tải.",
"navigation.skipToMain": "Chuyển đến nội dung chính",
"noData": "Không có dữ liệu",
"operation.add": "Thêm",
"operation.added": "Đã thêm",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "文本转语音模型",
"modelProvider.ttsModel.tip": "设置对话中文字转语音输出的默认使用模型。",
"modelProvider.upgradeForLoadBalancing": "升级以解锁负载均衡功能",
"navigation.skipToMain": "跳转到主要内容",
"noData": "暂无数据",
"operation.add": "添加",
"operation.added": "已添加",

View File

@ -521,6 +521,7 @@
"modelProvider.ttsModel.key": "文字轉語音模型",
"modelProvider.ttsModel.tip": "設定對話中文字轉語音輸出的預設使用模型。",
"modelProvider.upgradeForLoadBalancing": "升級您的計劃以啟用 Load Balancing。",
"navigation.skipToMain": "跳至主要內容",
"noData": "無資料",
"operation.add": "新增",
"operation.added": "已新增",