diff --git a/packages/shared/src/components/PlatformFilterPanel.test.tsx b/packages/shared/src/components/PlatformFilterPanel.test.tsx new file mode 100644 index 000000000..7165d0ca6 --- /dev/null +++ b/packages/shared/src/components/PlatformFilterPanel.test.tsx @@ -0,0 +1,90 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { + PlatformFilterPanel, + PlatformFilterPanelField, +} from './PlatformFilterPanel'; + +describe('PlatformFilterPanel', () => { + test('renders a titled dialog shell and forwards close', () => { + const onClose = vi.fn(); + render( + + + 字段控件 + + , + ); + + expect(screen.getByRole('dialog', { name: '筛选资源' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: '筛选' })).toBeTruthy(); + expect(screen.getByText('所在区域')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '关闭筛选' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('renders every field passed in, in order, and no field of its own', () => { + render( + {}}> + + + + + + + , + ); + + expect(screen.getByLabelText('所在区域')).toBe( + screen.getByLabelText('区域'), + ); + }); + + test('lets the host own positioning and extra classes', () => { + render( + {}} + className="host-position-class" + > + + 字段控件 + + , + ); + + const panel = screen.getByRole('dialog', { name: '筛选资源' }); + expect(panel.className).toContain('host-position-class'); + // 组件不自带 position:定位必须由宿主注入,避免共享层猜宿主布局。 + expect(panel.className).not.toContain('absolute'); + expect(panel.className).not.toContain('fixed'); + }); +}); diff --git a/packages/shared/src/components/PlatformFilterPanel.tsx b/packages/shared/src/components/PlatformFilterPanel.tsx new file mode 100644 index 000000000..27c82707b --- /dev/null +++ b/packages/shared/src/components/PlatformFilterPanel.tsx @@ -0,0 +1,90 @@ +import { X } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { PlatformFieldLabel } from './PlatformFieldLabel'; + +export type PlatformFilterPanelFieldProps = { + /** 字段名,例如「所在区域」。 */ + label: string; + /** 字段控件;由宿主持有取值与回调,本组件不解释任何筛选语义。 */ + children: ReactNode; + /** 控件 id,用于把字段名与控件关联起来。不传则只渲染视觉字段名。 */ + controlId?: string; +}; + +/** + * 筛选面板里的一个竖向字段。 + * + * 只负责「字段名在上、控件在下」这一条排布约定,字段名与控件都由宿主给出。 + */ +export function PlatformFilterPanelField({ + label, + children, + controlId, +}: PlatformFilterPanelFieldProps) { + return ( +
+ + {children} +
+ ); +} + +export type PlatformFilterPanelProps = { + /** 浮层无障碍名称,例如「筛选资源」。 */ + ariaLabel: string; + /** 浮层标题。 */ + title: string; + /** 右上角关闭键的无障碍名称。 */ + closeLabel?: string; + onClose: () => void; + /** 竖向排布的字段,通常由若干 `PlatformFilterPanelField` 组成。 */ + children: ReactNode; + className?: string; +}; + +/** + * 按需弹出的筛选浮层外壳。 + * + * 只承载通用表现:竖向字段容器、右上角关闭键与 `dialog` 语义。字段有哪些、每个字段 + * 取什么值、筛选怎么算,全部由宿主持有——因此同一个外壳可以在不同页面各自装一套 + * 字段,不把任何领域规则下沉到共享层。 + * + * 定位交给宿主:调用方通过 `className` 给出绝对定位与层级,组件本身不自带 position。 + */ +export function PlatformFilterPanel({ + ariaLabel, + title, + closeLabel = '关闭筛选', + onClose, + children, + className, +}: PlatformFilterPanelProps) { + return ( +
+
+

{title}

+ +
+
{children}
+
+ ); +}