新增共享筛选浮层外壳 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user