fix: correct page landmarks and document layout rules (#41938)

This commit is contained in:
Joel 2026-09-08 04:22:04 +00:00 committed by GitHub
parent d978fe710f
commit efe506a0f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 260 additions and 115 deletions

View File

@ -26,6 +26,14 @@ Flag:
Prefer semantic HTML before ARIA.
## Page Landmarks
When reviewing Web page layouts, navigation, or landmark changes, read [Page landmarks].
Inspect the composed page with its parent layouts for missing or duplicate main regions,
inappropriate nesting or roles, and ambiguous or broken landmark names. Check affected
unit and E2E locators when semantic elements change. Web owns these composition rules;
use the existing naming contract for label-source decisions.
## Accessible Names And Descriptions
Read [Accessible names and descriptions] when a change affects labels, ARIA naming, help/error relationships, or hidden text. That document owns the shared implementation and review contract.
@ -123,4 +131,5 @@ Flag:
- Hardcoded dates, times, numbers, or currency formats instead of `Intl.*`.
[Accessible names and descriptions]: ../../../../packages/dify-ui/docs/accessible-names-and-descriptions.md
[Page landmarks]: ../../../../web/docs/landmarks.md
[Web Interface Guidelines]: https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md

View File

@ -9,6 +9,7 @@
Web owns application-specific requirements and consumes shared architecture guidance from skills and primitive contracts from Dify UI. Link to those owners instead of redefining their rules here.
- For truncated text disclosure and native `title` decisions, follow [Truncated Text Disclosure].
- When adding or changing page layouts, navigation, or landmark elements, follow [Page landmarks] and inspect the composed page, including parent layouts.
- User-facing strings must use `web/i18n/en-US/` keys. When adding or renaming a key, update every supported locale with the correct localized value.
- For new backend calls and migrated surfaces, use generated `consoleQuery` / `consoleClient` APIs from `@/service/console`. Do not add handwritten REST helpers or DTO mirrors, mock-backed app state, or direct edits to generated contracts.
- Prefer `@langgenius/dify-ui/*` primitives, data attributes, and design tokens. Use the [Dify UI package index] to find a primitive; read the relevant contract directly when it is already known. Preserve a visible focus indicator on the final focusable element.
@ -36,5 +37,6 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
[Dify UI package index]: ../packages/dify-ui/README.md
[IconButton contract]: ../packages/dify-ui/src/icon-button/README.md
[Input Group contract]: ../packages/dify-ui/src/input-group/README.md
[Page landmarks]: docs/landmarks.md
[Truncated Text Disclosure]: docs/truncated-text-disclosure.md
[form contract]: ../packages/dify-ui/docs/forms.md

View File

@ -202,7 +202,7 @@ const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentP
{t(($) => $['accessRule.appDescription'], { ns: 'permission' })}
</p>
</header>
<main className="flex min-h-0 w-full max-w-240 flex-1 flex-col px-6 pt-8 pb-10 sm:pr-20 sm:pl-12.5">
<div className="flex min-h-0 w-full max-w-240 flex-1 flex-col px-6 pt-8 pb-10 sm:pr-20 sm:pl-12.5">
<AccessRulesEditor
className="min-h-0 w-full flex-1"
rules={appAccessRules}
@ -227,7 +227,7 @@ const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentP
onBatchRemoveAccessPolicyMemberBindings={handleBatchRemoveAccessPolicyMemberBindings}
onAddAccessSubject={handleAddAccessSubject}
/>
</main>
</div>
</div>
)
}

View File

@ -67,7 +67,7 @@ function AccessPointContent({
environment === selectedEnvironment ? highlightedAccessPoint : null
return (
<main className="flex h-full min-h-0 flex-col bg-components-panel-bg">
<div className="flex h-full min-h-0 flex-col bg-components-panel-bg">
<header className="flex shrink-0 flex-col gap-3 px-6 pt-3 pb-2">
<div className="flex flex-col gap-0.5">
<div className="flex h-6 items-center">
@ -145,7 +145,7 @@ function AccessPointContent({
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
</main>
</div>
)
}

View File

@ -108,7 +108,7 @@ function AppDeployContent({
return (
<>
<main className="flex h-full flex-col bg-components-panel-bg">
<div className="flex h-full flex-col bg-components-panel-bg">
<header className="flex shrink-0 flex-col gap-y-0.5 px-6 pt-3 pb-2">
<div className="flex h-6 items-center">
<h1 className="title-xl-semi-bold text-text-primary">
@ -140,7 +140,7 @@ function AppDeployContent({
onUndeploy={undeployWorkflow}
/>
</div>
</main>
</div>
{deploymentRequest && (
<DeploymentDialog
appId={appId}

View File

@ -231,7 +231,7 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) =>
{t(($) => $['accessRule.datasetDescription'], { ns: 'permission' })}
</p>
</header>
<main className="flex min-h-0 w-full max-w-240 flex-1 flex-col px-6 pt-8 pb-10 sm:pr-20 sm:pl-12.5">
<div className="flex min-h-0 w-full max-w-240 flex-1 flex-col px-6 pt-8 pb-10 sm:pr-20 sm:pl-12.5">
<AccessRulesEditor
className="min-h-0 w-full flex-1"
rules={datasetAccessRules}
@ -258,7 +258,7 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) =>
onBatchRemoveAccessPolicyMemberBindings={handleBatchRemoveAccessPolicyMemberBindings}
onAddAccessSubject={handleAddAccessSubject}
/>
</main>
</div>
</div>
)
}

View File

@ -299,33 +299,22 @@ describe('Datasets', () => {
expect(screen.queryByRole('status', { name: /common\.loading/ })).not.toBeInTheDocument()
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
})
it('should show Loading component when isFetchingNextPage is true', () => {
render(<Datasets {...defaultProps} hasNextPage={true} isFetchingNextPage={true} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should NOT show Loading component when isFetchingNextPage is false', () => {
render(<Datasets {...defaultProps} hasNextPage={true} isFetchingNextPage={false} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
})
describe('DatasetList null handling', () => {
it('should handle null datasetList gracefully', () => {
render(<Datasets {...defaultProps} datasetList={null} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle undefined datasetList gracefully', () => {
render(<Datasets {...defaultProps} datasetList={undefined} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('should handle empty pages array', () => {
render(<Datasets {...defaultProps} datasetList={createDatasetListData([])} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it.each([null, undefined, createDatasetListData([])])(
'shows the empty state when dataset data is unavailable or empty (%j)',
(datasetList) => {
render(
<Datasets
{...defaultProps}
datasetList={datasetList}
emptyElement={<p>No knowledge bases</p>}
/>,
)
expect(screen.getByText('No knowledge bases')).toBeInTheDocument()
},
)
})
describe('IntersectionObserver', () => {
@ -409,20 +398,18 @@ describe('Datasets', () => {
})
})
describe('Styles', () => {
it('should have correct grid styling', () => {
render(<Datasets {...defaultProps} />)
const nav = screen.getByRole('navigation')
expect(nav).toHaveClass(
'relative',
'grid',
'grow',
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
'content-start',
'gap-3',
'px-8',
'pt-2',
describe('Landmarks', () => {
it('keeps knowledge cards in the page content without creating another navigation landmark', () => {
render(
<Datasets
{...defaultProps}
datasetList={createDatasetListData([
{ data: [createMockDataset({ id: 'dataset-1', name: 'Dataset 1' })] },
])}
/>,
)
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
})
})

View File

@ -69,7 +69,7 @@ const Datasets = ({
return (
<>
<nav className="relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3 px-8 pt-2">
<div className="relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3 px-8 pt-2">
{showDatasetSkeleton ? (
<DatasetCardSkeleton label={t(($) => $.loading, { ns: 'common' })} />
) : (
@ -92,7 +92,7 @@ const Datasets = ({
{!showDatasetSkeleton && !hasAnyDataset && emptyElement}
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</nav>
</div>
</>
)
}

View File

@ -366,7 +366,10 @@ describe('IntegrationsPage', () => {
expect(screen.getByTestId('model-provider-page')).toBeInTheDocument()
expect(screen.getAllByText('common.settings.provider')).toHaveLength(2)
expect(container.firstElementChild).toHaveClass('bg-components-panel-bg')
expect(container.querySelector('aside')).toHaveClass('bg-components-panel-bg')
expect(
screen.getByRole('navigation', { name: 'common.settings.integrations' }),
).toBeInTheDocument()
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
})
it('does not replace the document title when embedded in a modal', () => {

View File

@ -12,7 +12,7 @@ import {
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useEffect, useState } from 'react'
import { useEffect, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import UpdateSettingDialog from '@/app/components/header/account-setting/update-setting-dialog'
import {
@ -126,6 +126,7 @@ export default function IntegrationsPage({
syncDocumentTitle = false,
}: IntegrationsPageProps) {
const { t } = useTranslation()
const navigationTitleId = useId()
const docLink = useDocLink()
const router = useRouter()
const section = useIntegrationSection(routeSection)
@ -253,7 +254,7 @@ export default function IntegrationsPage({
{syncDocumentTitle && (
<IntegrationsDocumentTitle title={`${sectionTitle} · ${integrationsTitle}`} />
)}
<aside
<div
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-components-panel-bg px-2 py-2 transition-[width]',
'w-50 items-end',
@ -268,7 +269,10 @@ export default function IntegrationsPage({
)}
>
<div className="flex h-6 min-w-0 flex-1 items-center justify-center">
<div className="min-w-0 flex-1 title-2xl-semi-bold text-text-primary">
<div
id={navigationTitleId}
className="min-w-0 flex-1 title-2xl-semi-bold text-text-primary"
>
{t(($) => $['settings.integrations'], { ns: 'common' })}
</div>
</div>
@ -283,7 +287,10 @@ export default function IntegrationsPage({
{!showInstallAction && reserveInstallActionSlot && (
<div aria-hidden="true" className="h-8 w-full shrink-0" />
)}
<nav className={cn('shrink-0 space-y-px', reserveInstallActionSlot ? 'mt-6' : 'py-4')}>
<nav
aria-labelledby={navigationTitleId}
className={cn('shrink-0 space-y-px', reserveInstallActionSlot ? 'mt-6' : 'py-4')}
>
<IntegrationSidebarNavItem
item={providerItem}
onSelect={onSectionChange}
@ -337,7 +344,7 @@ export default function IntegrationsPage({
onPermissionChange={handlePermissionChange}
/>
)}
</aside>
</div>
<section className="flex min-w-0 flex-1 flex-col overflow-hidden">
{useFillLayout ? (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">

View File

@ -710,7 +710,7 @@ describe('MainNav', () => {
marketplaceLink.querySelector('.i-custom-vender-main-nav-marketplace-v2'),
).toBeInTheDocument()
expect(
within(screen.getByRole('navigation'))
within(screen.getByRole('navigation', { name: 'common.navigation.primary' }))
.getAllByRole('link')
.map((link) => link.getAttribute('href')),
).toEqual([

View File

@ -118,7 +118,10 @@ export function MainNav({ className }: MainNavProps) {
<div className="p-2">
<WorkspaceCard />
</div>
<nav className="isolate flex flex-col gap-px p-2">
<nav
aria-label={t(($) => $['navigation.primary'], { ns: 'common' })}
className="isolate flex flex-col gap-px p-2"
>
{navItems.map((item) => (
<MainNavLink key={item.href} item={item} pathname={pathname}>
{item.href === '/agents' && (

View File

@ -1,5 +1,5 @@
import type { CreatorProfileViewModel } from '../model'
import { fireEvent, render } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import CreatorProfileView from '../view'
@ -13,10 +13,6 @@ vi.mock('#i18n', async () => {
}
})
vi.mock('../creator-sidebar', () => ({
default: () => <aside>Creator sidebar</aside>,
}))
vi.mock('../creator-content', () => ({
default: () => <section>Creator content</section>,
}))
@ -35,6 +31,26 @@ const profile: CreatorProfileViewModel = {
}
describe('CreatorProfileView SSR background', () => {
it('keeps the creator profile within the parent main landmark', () => {
render(
<main>
<CreatorProfileView
profile={profile}
homeHref="/marketplace"
isMarketplacePlatform
getCreationAction={() => ({ type: 'link', href: '/' })}
/>
</main>,
)
const main = screen.getByRole('main')
expect(within(main).getByRole('heading', { level: 1, name: 'Creator' })).toBeInTheDocument()
expect(
within(main).getByRole('navigation', { name: 'marketplace.creatorProfile.breadcrumbLabel' }),
).toBeInTheDocument()
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
})
it('includes the default background in server markup before the remote background loads', () => {
const markup = renderToStaticMarkup(
<CreatorProfileView
@ -63,7 +79,7 @@ describe('CreatorProfileView SSR background', () => {
)
expect(markup).toContain('default-background.png')
expect(markup).not.toContain('<img')
expect(markup).not.toContain('src="/creator-background.png"')
expect(markup).toContain('border-0')
})

View File

@ -33,7 +33,7 @@ export default function CreatorSidebar({ profile }: CreatorSidebarProps) {
const isVerified = profile.badges.includes('verified')
return (
<aside className="relative flex min-w-0 flex-col gap-4 pt-11 md:w-[234px] md:pt-12">
<div className="relative flex min-w-0 flex-col gap-4 pt-11 md:w-[234px] md:pt-12">
<PublisherAvatar
avatarUrl={profile.avatarUrl}
name={profile.displayName}
@ -109,6 +109,6 @@ export default function CreatorSidebar({ profile }: CreatorSidebarProps) {
</div>
</div>
)}
</aside>
</div>
)
}

View File

@ -45,7 +45,7 @@ export default function CreatorProfileView({
return (
<div className="flex min-h-full shrink-0 flex-col bg-background-default">
{header}
<main
<div
className={cn(
'flex w-full flex-1 flex-col px-4',
isMarketplacePlatform ? 'md:px-6' : 'md:px-9',
@ -104,7 +104,7 @@ export default function CreatorProfileView({
/>
</div>
</div>
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,21 @@
import { screen, within } from '@testing-library/react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import SignInLayout from '../layout'
describe('SignInLayout landmarks', () => {
it('separates the site header, sign-in content, and copyright', () => {
render(
<SignInLayout>
<h1>Sign in</h1>
</SignInLayout>,
{ systemFeatures: { branding: { enabled: false } } },
)
const main = screen.getByRole('main')
expect(within(main).getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
expect(screen.getByRole('banner')).not.toContainElement(main)
expect(screen.getByRole('contentinfo')).toHaveTextContent('LangGenius')
expect(main).not.toContainElement(screen.getByRole('banner'))
expect(main).not.toContainElement(screen.getByRole('contentinfo'))
})
})

View File

@ -19,7 +19,7 @@ const Header = () => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
return (
<div className="flex w-full items-center justify-between p-6">
<header className="flex w-full items-center justify-between p-6">
{systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo ? (
<img
src={systemFeatures.branding.login_page_logo}
@ -40,7 +40,7 @@ const Header = () => {
<Divider type="vertical" className="mx-0 ml-2 h-4" />
<ThemeSelector />
</div>
</div>
</header>
)
}

View File

@ -15,15 +15,15 @@ export default function SignInLayout({ children }: any) {
)}
>
<Header />
<div
<main
className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-27')}
>
<div className="flex w-full flex-col md:w-100">{children}</div>
</div>
</main>
{systemFeatures.branding.enabled === false && (
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
<footer className="px-8 py-6 system-xs-regular text-text-tertiary">
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
</div>
</footer>
)}
</div>
</div>

57
web/docs/landmarks.md Normal file
View File

@ -0,0 +1,57 @@
# Page Landmarks
Web owns landmark composition across route layouts and feature components. Use the
[W3C landmark guide] for role semantics and the Dify UI [naming contract] for accessible
names. This guide records how those contracts apply to Web pages.
## Page Ownership
- Place perceivable page content in meaningful landmark regions and check coverage after
composing the layout and its children.
- Trace the parent layouts before adding a page-level landmark. Inspect the composed
page, including parallel route slots and portals, rather than judging a component alone.
- [MainNavLayout] provides the console's `main` and skip-navigation target. Descendant
pages and editors must not create another `main`. Hiding the main navigation does not
remove this wrapper. A route outside this layout must establish its own main-content
boundary in its owning layout or page.
- Keep the skip link and its target under the same layout owner. Preserve the target ID
and focus behavior when changing wrappers; a landmark role alone does not move focus.
- Keep page and section headings meaningful inside these boundaries. A heading does not
create a landmark or automatically name its surrounding section.
## Choose Roles by Content
- Use `nav` for a meaningful group of navigation links. A grid of resource cards is not
automatically navigation merely because its cards can open detail pages.
- A visual sidebar is not automatically complementary content. File controls, editors,
and builder or version panels are parts of the current task. Use ordinary containers
or named sections according to their navigation value. Follow APG's recommendation
to keep genuine complementary landmarks at the top level.
- Reserve named `section` regions for content useful to reach directly. Avoid turning
every wrapper, card, or modal body into a landmark. Prefer an existing visible heading
as the naming source, following the [naming contract].
- Give repeated landmarks distinct names by purpose unless their content and purpose
are identical. Keep names localized and avoid repeating the role in the name.
- Page-level `header` and `footer` can expose `banner` and `contentinfo`. Their implicit
roles depend on ancestors; local panel headers and footers do not necessarily expose
those landmarks. Check the composed context before adding explicit roles.
## Verify the Composed Page
- Check the main-content boundary, landmark hierarchy, names, and heading relationships
in the relevant loaded, empty, collapsed, and open-overlay states. Exclude hidden
skeletons from the exposed landmark inventory. Check that label references still
resolve when conditional content changes.
- When changing a semantic element, inspect affected unit tests and E2E locators,
including CSS selectors such as `closest('main')`. Locate the intended feature or
control instead of relying on an extra page landmark or positional selection among
duplicate landmarks.
- Follow [Web testing policy] for regression coverage. At composition boundaries, keep
the components that own landmark semantics real; a mocked sidebar cannot prove the
real sidebar's role. Distinguish DOM assertions and Cucumber dry-runs from actual
browser or assistive-technology verification.
[MainNavLayout]: ../app/components/main-nav/layout.tsx
[W3C landmark guide]: https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions
[Web testing policy]: test.md
[naming contract]: ../../packages/dify-ui/docs/accessible-names-and-descriptions.md

View File

@ -960,7 +960,7 @@ export function AddSourcePage({
return (
<>
<main className="min-h-full px-4 py-6 sm:px-8 sm:py-7">
<div className="min-h-full px-4 py-6 sm:px-8 sm:py-7">
<header>
<h2 className="title-xl-semi-bold text-text-primary">
{t(($) => $['newKnowledge.addSource'])}
@ -1111,7 +1111,7 @@ export function AddSourcePage({
</p>
)}
</div>
</main>
</div>
<AddSourceExitDialog
discarding={discarding}
error={discardError}

View File

@ -544,7 +544,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
])
return (
<main className="flex min-h-full flex-col px-4 py-6 sm:px-8 sm:py-7">
<div className="flex min-h-full flex-col px-4 py-6 sm:px-8 sm:py-7">
<header>
<div>
<h2 className="title-xl-semi-bold text-text-primary">
@ -749,6 +749,6 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
) : null}
</>
)}
</main>
</div>
)
}

View File

@ -93,7 +93,7 @@ describe('SkillDetailPage builder', () => {
).not.toBeInTheDocument()
})
it('moves the collapsed Skill Builder entry into the file tab header', async () => {
it('reopens Skill Builder from the collapsed entry', async () => {
const user = userEvent.setup()
renderSkillDetailPage()
@ -106,12 +106,14 @@ describe('SkillDetailPage builder', () => {
const openBuilderButton = screen.getByRole('button', {
name: 'skill.skillManagement.detail.builder.open',
})
expect(openBuilderButton.closest('main')).toBeInTheDocument()
expect(
screen.queryByRole('region', { name: 'skill.skillManagement.detail.builder.title' }),
).not.toBeInTheDocument()
await user.click(openBuilderButton)
expect(
await screen.findByRole('button', {
name: 'skill.skillManagement.detail.builder.close',
await screen.findByRole('region', {
name: 'skill.skillManagement.detail.builder.title',
}),
).toBeInTheDocument()
})

View File

@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import {
createDataTransfer,
createSkillDetail,
getFileTabButton,
getFileTreeButton,
getFileTreeContextRegion,
getFileTreeItem,
@ -247,13 +248,7 @@ describe('SkillDetailPage clipboard', () => {
)
})
await waitFor(() => {
const editorMain = screen.getAllByRole('main').at(-1)
if (!editorMain) throw new Error('file editor not found')
expect(
within(editorMain).getByRole('button', {
name: 'SKILL copy.md',
}),
).toBeInTheDocument()
expect(getFileTabButton('SKILL copy.md')).toHaveAccessibleName('SKILL copy.md')
})
})

View File

@ -51,12 +51,21 @@ describe('SkillDetailPage navigation', () => {
const user = userEvent.setup()
renderSkillDetailPage()
expect(
await screen.findByRole('region', { name: /skillManagement\.detail\.fileCount/ }),
).toBeInTheDocument()
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
expect(screen.queryByRole('main')).not.toBeInTheDocument()
await user.click(
await screen.findByRole('button', {
name: 'skill.skillManagement.detail.collapseSidebar',
}),
)
expect(screen.queryByTestId('skill-detail-sidebar-header')).not.toBeInTheDocument()
expect(
screen.queryByRole('region', { name: /skillManagement\.detail\.fileCount/ }),
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
@ -64,6 +73,9 @@ describe('SkillDetailPage navigation', () => {
}),
)
expect(await screen.findByTestId('skill-detail-sidebar-header')).toBeInTheDocument()
expect(
screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ }),
).toBeInTheDocument()
})
it('shows the sidebar while the collapsed rail is hovered', async () => {

View File

@ -517,7 +517,7 @@ describe('SkillDetailPage publishing', () => {
await screen.findByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
)
await screen.findByText('skill.skillManagement.detail.versions')
await screen.findByRole('region', { name: 'skill.skillManagement.detail.versions' })
expect(screen.getAllByRole('button', { current: true })).toHaveLength(1)
await user.click(

View File

@ -645,13 +645,9 @@ export async function openFileTreeActions(user: ReturnType<typeof userEvent.setu
}
export async function openRootCreateMenu(user: ReturnType<typeof userEvent.setup>) {
const triggers = Array.from(document.querySelectorAll('aside .i-ri-add-line'))
.map((icon) => icon.closest('button'))
.filter((button): button is HTMLButtonElement => button instanceof HTMLButtonElement)
const trigger = triggers.at(-1)
if (!(trigger instanceof HTMLButtonElement)) throw new Error('root create menu trigger not found')
const fileTree = screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ })
await user.click(trigger)
await user.click(within(fileTree).getByRole('button', { name: 'common.operation.add' }))
}
export async function confirmUploadReview() {

View File

@ -11,7 +11,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Markdown } from '@/app/components/base/markdown'
import {
@ -298,6 +298,7 @@ export function SkillBuilderPanel({
}) {
const { t } = useTranslation('skill')
const queryClient = useQueryClient()
const titleId = useId()
const [prompt, setPrompt] = useState('')
const initialBuilderModeRef = useRef({
isEditMode: !isDefaultSkillBuilderDraft(detail),
@ -803,7 +804,10 @@ export function SkillBuilderPanel({
}
return (
<aside className="relative my-1 mr-1 flex w-99 shrink-0 flex-col overflow-hidden rounded-lg inset-ring-[0.5px] inset-ring-divider-subtle">
<section
aria-labelledby={titleId}
className="relative my-1 mr-1 flex w-99 shrink-0 flex-col overflow-hidden rounded-lg inset-ring-[0.5px] inset-ring-divider-subtle"
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0 bg-linear-to-b from-background-gradient-bg-fill-chat-bg-1 to-background-gradient-bg-fill-chat-bg-2"
@ -817,7 +821,7 @@ export function SkillBuilderPanel({
className="pointer-events-none absolute bottom-0 left-0 z-1 origin-center scale-y-[-1]"
/>
<div className="relative z-10 flex h-12 shrink-0 items-center justify-between gap-2 pr-3 pl-4">
<h2 className="system-xs-semibold-uppercase text-text-secondary">
<h2 id={titleId} className="system-xs-semibold-uppercase text-text-secondary">
{t(($) => $['skillManagement.detail.builder.title'])}
</h2>
<div className="flex h-8 items-center gap-1">
@ -1119,6 +1123,6 @@ export function SkillBuilderPanel({
</div>
</div>
</div>
</aside>
</section>
)
}

View File

@ -1075,7 +1075,7 @@ export function FileEditor({
}
return (
<main className="relative my-1 mr-1 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg bg-background-default inset-ring-[0.5px] inset-ring-divider-subtle">
<div className="relative my-1 mr-1 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg bg-background-default inset-ring-[0.5px] inset-ring-divider-subtle">
<FileTabs
endAction={
onOpenBuilder && (
@ -1518,6 +1518,6 @@ export function FileEditor({
onExit={onExitVersion}
/>
)}
</main>
</div>
)
}

View File

@ -55,7 +55,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay, matchesKeyboardEvent, useHotkey } from '@tanstack/react-hotkeys'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import copy from 'copy-to-clipboard'
import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
@ -175,6 +175,7 @@ export function FileTree({
const { t: tCommon } = useTranslation('common')
const queryClient = useQueryClient()
const sidebarRef = useRef<HTMLElement>(null)
const filesTitleId = useId()
const uploadInputRef = useRef<HTMLInputElement>(null)
const [inlineAction, setInlineAction] = useState<FileTreeInlineAction>()
const [draggingPaths, setDraggingPaths] = useState<string[]>([])
@ -1105,7 +1106,7 @@ export function FileTree({
const creatorName = detail?.created_by_name ?? detail?.created_by ?? '-'
if (collapsed && !sidebarFloating) {
return (
<aside
<div
data-testid="skill-detail-sidebar-shell"
className="relative flex h-full w-16 shrink-0 bg-background-body p-1"
onMouseEnter={openSidebarFloatingPreview}
@ -1137,13 +1138,14 @@ export function FileTree({
<SkillSidebarAccountFooter compact />
</div>
</div>
</aside>
</div>
)
}
return (
<>
<aside
<section
aria-labelledby={filesTitleId}
ref={sidebarRef}
data-testid="skill-detail-sidebar-shell"
className={cn(
@ -1313,7 +1315,10 @@ export function FileTree({
<div className="h-px w-full bg-linear-to-r from-divider-subtle to-transparent" />
</div>
<div className="flex h-8 shrink-0 items-center gap-1 px-3">
<h2 className="min-w-0 flex-1 system-xs-medium-uppercase text-text-tertiary">
<h2
id={filesTitleId}
className="min-w-0 flex-1 system-xs-medium-uppercase text-text-tertiary"
>
{t(
($) =>
fileCount === 1
@ -1325,6 +1330,7 @@ export function FileTree({
{!readonly && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={tCommon(($) => $['operation.add'])}
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
disabled={!detail || isMutating}
>
@ -1601,7 +1607,7 @@ export function FileTree({
</div>
<SkillSidebarAccountFooter />
</div>
</aside>
</section>
</>
)
}

View File

@ -6,7 +6,7 @@ import { SkillBuilderGridTexture } from './builder-grid-texture'
export function DetailSkeleton() {
return (
<div aria-busy="true" className="flex h-0 min-w-0 grow overflow-hidden bg-background-body">
<aside aria-hidden className="flex h-full w-62 shrink-0 bg-background-body p-1">
<div aria-hidden className="flex h-full w-62 shrink-0 bg-background-body p-1">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg bg-components-panel-bg">
<div className="flex h-12 shrink-0 items-center gap-1 px-2">
<SkeletonRectangle className="my-0 size-6 rounded-md opacity-12" />
@ -38,9 +38,9 @@ export function DetailSkeleton() {
<SkeletonRectangle className="my-0 h-8 w-full rounded-lg opacity-12" />
</div>
</div>
</aside>
</div>
<main
<div
aria-hidden
className="my-1 mr-1 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg bg-background-default inset-ring-[0.5px] inset-ring-divider-subtle"
>
@ -68,9 +68,9 @@ export function DetailSkeleton() {
</div>
</div>
</div>
</main>
</div>
<aside
<div
aria-hidden
className="relative my-1 mr-1 flex w-99 shrink-0 flex-col overflow-hidden rounded-lg inset-ring-[0.5px] inset-ring-divider-subtle"
>
@ -99,7 +99,7 @@ export function DetailSkeleton() {
</div>
</div>
</div>
</aside>
</div>
</div>
)
}

View File

@ -33,7 +33,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/console'
@ -506,6 +506,7 @@ export function VersionPanel({
const { t } = useTranslation('skill')
const { t: tWorkflow } = useTranslation('workflow')
const [filterValue, setFilterValue] = useState<VersionFilterValue>('all')
const titleId = useId()
const filteredVersions = versions.filter((version) => {
if (filterValue === 'onlyNamed') return !!version.version_name
@ -513,10 +514,10 @@ export function VersionPanel({
})
return (
<aside className="flex w-67 shrink-0 flex-col py-1">
<section aria-labelledby={titleId} className="flex w-67 shrink-0 flex-col py-1">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-l-lg bg-components-panel-bg">
<div className="flex shrink-0 items-center gap-2 pt-3 pr-3 pl-4">
<h2 className="min-w-0 flex-1 truncate system-xl-semibold text-text-primary">
<h2 id={titleId} className="min-w-0 flex-1 truncate system-xl-semibold text-text-primary">
{t(($) => $['skillManagement.detail.versions'])}
</h2>
<VersionFilter value={filterValue} onChange={setFilterValue} />
@ -574,6 +575,6 @@ export function VersionPanel({
</div>
</div>
</div>
</aside>
</section>
)
}

View File

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

View File

@ -441,6 +441,7 @@
"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.",
"modelProvider.used": "verwendet",
"navigation.primary": "Hauptmenü",
"navigation.skipToMain": "Zum Hauptinhalt springen",
"noData": "Keine Daten",
"operation.add": "Hinzufügen",

View File

@ -441,6 +441,7 @@
"modelProvider.ttsModel.tip": "Set the default model for text-to-speech input in conversation.",
"modelProvider.upgradeForLoadBalancing": "Upgrade your plan to enable Load Balancing.",
"modelProvider.used": "used",
"navigation.primary": "Main menu",
"navigation.skipToMain": "Skip to main content",
"noData": "No data",
"operation.add": "Add",

View File

@ -441,6 +441,7 @@
"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.",
"modelProvider.used": "usados",
"navigation.primary": "Menú principal",
"navigation.skipToMain": "Saltar al contenido principal",
"noData": "Sin datos",
"operation.add": "Agregar",

View File

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

View File

@ -441,6 +441,7 @@
"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.",
"modelProvider.used": "utilisés",
"navigation.primary": "Menu principal",
"navigation.skipToMain": "Aller au contenu principal",
"noData": "Aucune donnée",
"operation.add": "Ajouter",

View File

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

View File

@ -441,6 +441,7 @@
"modelProvider.ttsModel.tip": "Atur model default untuk input teks-ke-ucapan dalam percakapan.",
"modelProvider.upgradeForLoadBalancing": "Tingkatkan paket Anda untuk mengaktifkan Penyeimbangan Beban.",
"modelProvider.used": "digunakan",
"navigation.primary": "Menu utama",
"navigation.skipToMain": "Lewati ke konten utama",
"noData": "Tidak ada data",
"operation.add": "Tambah",

View File

@ -441,6 +441,7 @@
"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.",
"modelProvider.used": "utilizzati",
"navigation.primary": "Menu principale",
"navigation.skipToMain": "Vai al contenuto principale",
"noData": "Nessun dato",
"operation.add": "Aggiungi",

View File

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

View File

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

View File

@ -441,6 +441,7 @@
"modelProvider.ttsModel.tip": "ຕັ້ງຄ່າໂມເດວເລີ່ມຕົ້ນສຳລັບການປ່ຽນຂໍ້ຄວາມເປັນສຽງໃນການສົນທະນາ.",
"modelProvider.upgradeForLoadBalancing": "ອັບເກຣດແຜນຂອງທ່ານເພື່ອເປີດໃຊ້ Load Balancing.",
"modelProvider.used": "ໃຊ້ໄປແລ້ວ",
"navigation.primary": "ເມນູຫຼັກ",
"navigation.skipToMain": "ຂ້າມໄປເນື້ອຫາຫຼັກ",
"noData": "ບໍ່ມີຂໍ້ມູນ",
"operation.add": "ເພີ່ມ",

View File

@ -441,6 +441,7 @@
"modelProvider.ttsModel.tip": "Set the default model for text-to-speech input in conversation.",
"modelProvider.upgradeForLoadBalancing": "Upgrade your plan to enable Load Balancing.",
"modelProvider.used": "gebruikt",
"navigation.primary": "Hoofdmenu",
"navigation.skipToMain": "Naar hoofdinhoud springen",
"noData": "No data",
"operation.add": "Add",

View File

@ -441,6 +441,7 @@
"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.",
"modelProvider.used": "wykorzystano",
"navigation.primary": "Menu główne",
"navigation.skipToMain": "Przejdź do głównej treści",
"noData": "Brak danych",
"operation.add": "Dodaj",

View File

@ -441,6 +441,7 @@
"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.",
"modelProvider.used": "usados",
"navigation.primary": "Menu principal",
"navigation.skipToMain": "Pular para o conteúdo principal",
"noData": "Sem dados",
"operation.add": "Adicionar",

View File

@ -441,6 +441,7 @@
"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.",
"modelProvider.used": "utilizate",
"navigation.primary": "Meniu principal",
"navigation.skipToMain": "Sări la conținutul principal",
"noData": "Fără date",
"operation.add": "Adaugă",

View File

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

View File

@ -441,6 +441,7 @@
"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.",
"modelProvider.used": "porabljeno",
"navigation.primary": "Glavni meni",
"navigation.skipToMain": "Preskoči na glavno vsebino",
"noData": "Ni podatkov",
"operation.add": "Dodaj",

View File

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

View File

@ -441,6 +441,7 @@
"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.",
"modelProvider.used": "kullanıldı",
"navigation.primary": "Ana menü",
"navigation.skipToMain": "Ana içeriğe geç",
"noData": "Veri yok",
"operation.add": "Ekle",

View File

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

View File

@ -441,6 +441,7 @@
"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.",
"modelProvider.used": "đã dùng",
"navigation.primary": "Menu chính",
"navigation.skipToMain": "Chuyển đến nội dung chính",
"noData": "Không có dữ liệu",
"operation.add": "Thêm",

View File

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

View File

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