新增共享筛选浮层外壳 PlatformFilterPanel

- packages/shared/src/components/PlatformFilterPanel.tsx:新增按需弹出的筛选浮层外壳,只承载通用表现——竖向字段容器、右上角关闭键与 role=dialog 语义;字段名与控件全部由宿主经 children 传入
- 字段排布约定收敛到 PlatformFilterPanelField:字段名在上、控件在下,可用 controlId 把字段名与控件关联;字段名复用既有 PlatformFieldLabel,不另写标签样式
- 不下沉任何领域规则:外壳不知道区域 / 标签 / 状态等概念,不接后端,不算过滤;筛选取值与判据都由宿主持有,因此同一外壳可在不同页面各装一套字段
- 不自带 position:定位与层级由宿主经 className 注入,避免共享层猜宿主布局
- packages/shared/src/components/PlatformFilterPanel.test.tsx:覆盖 dialog 语义与关闭转发、字段逐个渲染且不出现外壳自带的「状态」占位字段、controlId 字段名关联、以及宿主定位类注入且组件不自带 absolute / fixed
This commit is contained in:
2026-09-11 19:43:51 +08:00
parent d4e7b8bbaf
commit eb6f72ab9d
2 changed files with 180 additions and 0 deletions
@@ -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(
<PlatformFilterPanel ariaLabel="筛选资源" title="筛选" onClose={onClose}>
<PlatformFilterPanelField label="所在区域">
<span></span>
</PlatformFilterPanelField>
</PlatformFilterPanel>,
);
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(
<PlatformFilterPanel ariaLabel="筛选资源" title="筛选" onClose={() => {}}>
<PlatformFilterPanelField label="查找素材">
<input aria-label="关键词" />
</PlatformFilterPanelField>
<PlatformFilterPanelField label="所在区域">
<select aria-label="区域" />
</PlatformFilterPanelField>
<PlatformFilterPanelField label="自定义标签">
<span></span>
</PlatformFilterPanelField>
</PlatformFilterPanel>,
);
// 外壳自己不知道任何字段语义:字段名只可能来自宿主传入的 children。
expect(screen.getByText('查找素材')).toBeTruthy();
expect(screen.getByText('所在区域')).toBeTruthy();
expect(screen.getByText('自定义标签')).toBeTruthy();
expect(screen.getByLabelText('关键词')).toBeTruthy();
expect(screen.getByLabelText('区域')).toBeTruthy();
// 「状态」不在本产品范围内(系统没有资源状态事实源),外壳不得自带占位字段。
expect(screen.queryByText('状态')).toBeNull();
expect(screen.queryByText('全部状态')).toBeNull();
});
test('associates a field label with its control id when provided', () => {
render(
<PlatformFilterPanel ariaLabel="筛选资源" title="筛选" onClose={() => {}}>
<PlatformFilterPanelField label="所在区域" controlId="region-select">
<select id="region-select" aria-label="区域" />
</PlatformFilterPanelField>
</PlatformFilterPanel>,
);
expect(screen.getByLabelText('所在区域')).toBe(
screen.getByLabelText('区域'),
);
});
test('lets the host own positioning and extra classes', () => {
render(
<PlatformFilterPanel
ariaLabel="筛选资源"
title="筛选"
onClose={() => {}}
className="host-position-class"
>
<PlatformFilterPanelField label="所在区域">
<span></span>
</PlatformFilterPanelField>
</PlatformFilterPanel>,
);
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');
});
});
@@ -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 (
<div className="flex min-w-0 flex-col gap-1.5">
<label htmlFor={controlId}>
<PlatformFieldLabel>{label}</PlatformFieldLabel>
</label>
{children}
</div>
);
}
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 (
<section
role="dialog"
aria-label={ariaLabel}
className={[
'platform-theme platform-theme--light flex w-[15rem] max-w-[calc(100%-28px)] flex-col gap-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-3 text-[var(--platform-text-strong)] shadow-[0_6px_18px_rgb(112_70_52_/_12%)]',
className,
]
.filter(Boolean)
.join(' ')}
>
<header className="flex items-center justify-between gap-2">
<h2 className="text-sm font-black">{title}</h2>
<button
type="button"
aria-label={closeLabel}
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-full text-[var(--platform-text-muted)] hover:bg-black/5"
>
<X size={14} aria-hidden="true" />
</button>
</header>
<div className="flex min-w-0 flex-col gap-3">{children}</div>
</section>
);
}