新增资源筛选浮层组件与右下角锚点样式
- ResourceFilterPanel.tsx:AGC 侧筛选浮层,领域规则留在这一层——区域取值就是画布当前栏目(只把选择交回宿主,面板自己不改栏目也不另存区域),关键词与画布搜索浮层共用同一份状态,标签走 AND 语义;共享层只提供浮层外壳与表单原语 - ResourceFilterPanel.tsx:不渲染「状态」字段,并按注释写明理由——manifest 资产与资源投影都没有状态事实源,唯二候选(任务状态、附件导入状态)在投影里已被上游过滤,硬用即恒单值假筛选 - ResourceFilterPanel.tsx:「点外部 / Esc 关闭」按既有搜索浮层口径在 document 上同时接管 click 与 Escape,点触发按钮不被判成外部;不复用美术画布那个 dismiss hook,因为它判定焦点是否在浮层里用的是自己的 portal-menu 类,本浮层不匹配会导致 Esc 关闭后焦点不回触发按钮 - ResourceFilterPanel.tsx:区域下拉由 RESOURCE_FILTER_REGION_OPTIONS 派生,显示名与 @ 面板同一份口径;标签 chip 用既有 platform-category-chip 类,选中态不另造样式 - resourceCanvasFilterModel.ts:筛选生效判据 resourceFilterIsActive 落在模型层,供宿主给 Dock 按钮打高亮(条件不随浮层收起一起消失);纯空白关键词不计入,避免按钮为一次空输入长期高亮 - styles.css:新增 .game-resource-filter-panel 右下角锚点(与搜索浮层同一 bottom / z-index 口径,向上展开),定位留在宿主,共享浮层外壳保持不自带 position - tests/resourceFilterPanel.test.tsx 与 tests/resourceCanvasFilterModel.test.ts:覆盖三字段渲染且无「状态」占位、区域选项取值与中文显示名逐个钉住、关键词回显与回调、区域切换只回调、标签受控多选与 aria-pressed、空标签库隐藏该字段、关闭键 / Escape / 点外部 / 点触发按钮四种关闭口径、Escape 后焦点回触发按钮、筛选生效判据
This commit is contained in:
@@ -6204,6 +6204,25 @@ iframe.preview-frame {
|
||||
color: #b5582f;
|
||||
}
|
||||
|
||||
/*
|
||||
* 筛选浮层:与搜索浮层同一个右下角锚点、同一套向上展开口径(`bottom: 58px` =
|
||||
* Dock 的 `bottom: 14px` + Dock 高度 `34px` + 10px 间隙,`z-index: 40` 高于画本场景
|
||||
* 的 20),所以两者永远不会落到栏目标题栏那条带上,也不会互相遮挡视线。
|
||||
*
|
||||
* 定位留在宿主:共享的 `PlatformFilterPanel` 不自带 position,由这层给出锚点。
|
||||
*/
|
||||
.game-resource-filter-panel {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 58px;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.game-resource-filter-panel__card {
|
||||
max-height: calc(100% - 72px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.game-resource-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { type RefObject, useEffect, useId, useRef } from 'react';
|
||||
|
||||
import {
|
||||
PlatformFilterPanel,
|
||||
PlatformFilterPanelField,
|
||||
} from '../../../../../packages/shared/src/components/PlatformFilterPanel';
|
||||
import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge';
|
||||
import {
|
||||
PlatformSelectField,
|
||||
PlatformTextField,
|
||||
} from '../../../../../packages/shared/src/components/PlatformTextField';
|
||||
import {
|
||||
RESOURCE_FILTER_REGION_OPTIONS,
|
||||
type ResourceFilterRegion,
|
||||
type ResourceFilterTagOption,
|
||||
} from './resourceCanvasFilterModel';
|
||||
|
||||
/**
|
||||
* 已选标签的 chip 用与既有筛选条同一套类名,保持「选中的标签长什么样」只有一处实现。
|
||||
* `PlatformResourceFilterBar` 自己渲染未选标签时用的是同一个类。
|
||||
*/
|
||||
function resourceFilterTagChipClassName(active: boolean) {
|
||||
return [
|
||||
'platform-category-chip gap-1.5 px-2.5 text-xs font-bold',
|
||||
active ? 'platform-category-chip--active' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
type ResourceFilterPanelProps = {
|
||||
onClose: () => void;
|
||||
/** 关键词与画布搜索浮层共用同一份状态,这里只转发。 */
|
||||
keyword: string;
|
||||
onKeywordChange: (value: string) => void;
|
||||
/** 区域即画布当前栏目;本面板不改动栏目,只把选择交给宿主。 */
|
||||
region: ResourceFilterRegion;
|
||||
onRegionChange: (region: ResourceFilterRegion) => void;
|
||||
/** 已选标签,AND 语义。 */
|
||||
activeTags: readonly string[];
|
||||
onToggleTag: (tag: string) => void;
|
||||
/** 标签库由宿主按当前区域派生后传入。 */
|
||||
tagItems: readonly ResourceFilterTagOption[];
|
||||
/** 浮层自己与触发按钮:点按钮只走按钮的开合,不被判成外部点击。 */
|
||||
triggerRef: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源画布筛选浮层。
|
||||
*
|
||||
* 领域规则都留在这一层:区域取值就是画布栏目、关键词与搜索浮层共用一份状态、
|
||||
* 标签用 AND 语义。共享层只提供浮层外壳与表单原语,不知道这些概念。
|
||||
*/
|
||||
export function ResourceFilterPanel({
|
||||
onClose,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
region,
|
||||
onRegionChange,
|
||||
activeTags,
|
||||
onToggleTag,
|
||||
tagItems,
|
||||
triggerRef,
|
||||
}: ResourceFilterPanelProps) {
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
const idPrefix = useId();
|
||||
|
||||
/**
|
||||
* 「点外部 / Esc 关闭」。与既有搜索浮层同一口径:document 上同时接管 click 与
|
||||
* Escape,点在边界外即关、点在边界内保留;Escape 在 document 上截断,画布全局的
|
||||
* Esc(清选中)不会跟着一起触发。
|
||||
*
|
||||
* 这里不直接复用美术画布的 `useImageCanvasFloatingOptionDismiss`:那个 hook 判定
|
||||
* 「焦点是否在浮层里」用的是它自己的 `.image-canvas-editor__portal-menu` 类,本浮层
|
||||
* 不匹配,会导致 Esc 关闭后焦点不回到触发按钮,所以按同一口径在这里写。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
|
||||
const isInsideBoundary = (target: EventTarget | null) => {
|
||||
if (!(target instanceof Node)) return false;
|
||||
return (
|
||||
panelRef.current?.contains(target) === true ||
|
||||
triggerRef.current?.contains(target) === true
|
||||
);
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (isInsideBoundary(event.target)) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
// 画布全局快捷键在 window 上监听同一个 Escape;浮层打开时 Escape 归浮层所有。
|
||||
event.stopPropagation();
|
||||
const shouldRestoreFocus = isInsideBoundary(document.activeElement);
|
||||
onClose();
|
||||
if (shouldRestoreFocus) {
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [onClose, triggerRef]);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="game-resource-filter-panel">
|
||||
<PlatformFilterPanel
|
||||
ariaLabel="筛选资源"
|
||||
title="筛选"
|
||||
onClose={onClose}
|
||||
className="game-resource-filter-panel__card"
|
||||
>
|
||||
<PlatformFilterPanelField
|
||||
label="查找素材"
|
||||
controlId={`${idPrefix}-keyword`}
|
||||
>
|
||||
<PlatformTextField
|
||||
id={`${idPrefix}-keyword`}
|
||||
type="search"
|
||||
aria-label="查找素材"
|
||||
placeholder="名称、路径或类型"
|
||||
density="compact"
|
||||
value={keyword}
|
||||
onChange={(event) => onKeywordChange(event.currentTarget.value)}
|
||||
/>
|
||||
</PlatformFilterPanelField>
|
||||
|
||||
<PlatformFilterPanelField
|
||||
label="所在区域"
|
||||
controlId={`${idPrefix}-region`}
|
||||
>
|
||||
<PlatformSelectField
|
||||
id={`${idPrefix}-region`}
|
||||
aria-label="所在区域"
|
||||
density="compact"
|
||||
value={region}
|
||||
onChange={(event) =>
|
||||
onRegionChange(event.currentTarget.value as ResourceFilterRegion)
|
||||
}
|
||||
>
|
||||
{RESOURCE_FILTER_REGION_OPTIONS.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</PlatformSelectField>
|
||||
</PlatformFilterPanelField>
|
||||
|
||||
{/*
|
||||
「状态」字段不做:manifest 资产与资源投影都没有状态事实源,唯一的候选状态
|
||||
(任务状态、附件导入状态)在投影里已被上游过滤掉,硬用就是恒单值的假筛选。
|
||||
这里不渲染占位下拉。
|
||||
*/}
|
||||
{tagItems.length > 0 ? (
|
||||
<PlatformFilterPanelField label="自定义标签">
|
||||
<div
|
||||
className="flex min-w-0 flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label="自定义标签"
|
||||
>
|
||||
{tagItems.map((option) => {
|
||||
const active = activeTags.includes(option.tag);
|
||||
return (
|
||||
<button
|
||||
key={option.tag}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={resourceFilterTagChipClassName(active)}
|
||||
onClick={() => onToggleTag(option.tag)}
|
||||
>
|
||||
<span>{option.tag}</span>
|
||||
<PlatformPillBadge tone="neutral" size="xs">
|
||||
{option.assetCount}
|
||||
</PlatformPillBadge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PlatformFilterPanelField>
|
||||
) : null}
|
||||
</PlatformFilterPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -123,6 +123,20 @@ export function filterCanvasResources(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选条件是否生效。
|
||||
*
|
||||
* 供宿主给 Dock 按钮打高亮用:条件不会随浮层收起一起消失,按钮必须能看出
|
||||
* 「当前有筛选」,否则用户会以为资源丢了——沿用搜索按钮既有的 `is-active` 口径。
|
||||
*/
|
||||
export function resourceFilterIsActive(filter: ResourceCanvasFilter) {
|
||||
return (
|
||||
filter.region !== RESOURCE_FILTER_ALL_REGION ||
|
||||
filter.tags.length > 0 ||
|
||||
filter.keyword.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前资源集合派生可用标签库(去重、按使用次数降序)。
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
filterCanvasResources,
|
||||
RESOURCE_FILTER_ALL_REGION,
|
||||
RESOURCE_FILTER_REGION_OPTIONS,
|
||||
resourceFilterIsActive,
|
||||
resourceFilterRegionLabel,
|
||||
resourceMatchesCanvasFilter,
|
||||
resourceMatchesKeyword,
|
||||
@@ -255,6 +256,25 @@ describe('资源画布筛选模型', () => {
|
||||
expect(resourceMatchesRegion(UI_RESOURCE, 'character')).toBe(false);
|
||||
});
|
||||
|
||||
it('筛选生效判据:区域非全部、或有标签、或有关键词', () => {
|
||||
expect(
|
||||
resourceFilterIsActive({ region: 'all', tags: [], keyword: '' }),
|
||||
).toBe(false);
|
||||
// 纯空白关键词等同没有条件,否则按钮会为一次空输入长期高亮。
|
||||
expect(
|
||||
resourceFilterIsActive({ region: 'all', tags: [], keyword: ' ' }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resourceFilterIsActive({ region: 'audio', tags: [], keyword: '' }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resourceFilterIsActive({ region: 'all', tags: ['主页'], keyword: '' }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resourceFilterIsActive({ region: 'all', tags: [], keyword: '按钮' }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('标签库只统计传入区域内的资源,按使用次数降序、同次数按标签升序', () => {
|
||||
// 两个标签各被两条资源使用,次数相同 → 回退到 `zh-CN` 升序:节日 < 主页。
|
||||
expect(buildResourceFilterTagOptions(ALL_RESOURCES)).toEqual([
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ResourceFilterPanel } from '../src/view/project-development/ResourceFilterPanel';
|
||||
|
||||
function renderPanel(
|
||||
overrides: {
|
||||
keyword?: string;
|
||||
region?: 'all' | 'ui-interaction' | 'character';
|
||||
activeTags?: string[];
|
||||
tagItems?: Array<{ tag: string; assetCount: number }>;
|
||||
onKeywordChange?: (value: string) => void;
|
||||
onRegionChange?: (region: string) => void;
|
||||
onToggleTag?: (tag: string) => void;
|
||||
onClose?: () => void;
|
||||
} = {},
|
||||
) {
|
||||
const triggerRef = createRef<HTMLButtonElement>();
|
||||
const handlers = {
|
||||
onClose: overrides.onClose ?? vi.fn(),
|
||||
onKeywordChange: overrides.onKeywordChange ?? vi.fn(),
|
||||
onRegionChange: overrides.onRegionChange ?? vi.fn(),
|
||||
onToggleTag: overrides.onToggleTag ?? vi.fn(),
|
||||
};
|
||||
const view = render(
|
||||
<div>
|
||||
<button ref={triggerRef} type="button">
|
||||
筛选
|
||||
</button>
|
||||
<ResourceFilterPanel
|
||||
onClose={handlers.onClose}
|
||||
keyword={overrides.keyword ?? ''}
|
||||
onKeywordChange={handlers.onKeywordChange}
|
||||
region={overrides.region ?? 'all'}
|
||||
onRegionChange={handlers.onRegionChange as (region: 'all') => void}
|
||||
activeTags={overrides.activeTags ?? []}
|
||||
onToggleTag={handlers.onToggleTag}
|
||||
tagItems={
|
||||
overrides.tagItems ?? [
|
||||
{ tag: '主页', assetCount: 2 },
|
||||
{ tag: '节日', assetCount: 1 },
|
||||
]
|
||||
}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
return { ...handlers, ...view, triggerRef };
|
||||
}
|
||||
|
||||
describe('ResourceFilterPanel', () => {
|
||||
it('渲染关键词、区域与标签三个字段,且不渲染「状态」占位字段', () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '筛选资源' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('查找素材')).toBeTruthy();
|
||||
expect(screen.getByLabelText('所在区域')).toBeTruthy();
|
||||
expect(screen.getByRole('group', { name: '自定义标签' })).toBeTruthy();
|
||||
|
||||
// 「状态」没有事实源,不得出现占位下拉。
|
||||
expect(screen.queryByLabelText('状态')).toBeNull();
|
||||
expect(screen.queryByText('全部状态')).toBeNull();
|
||||
});
|
||||
|
||||
it('区域下拉取自现行分区栏目,顺序与画布栏目同源', () => {
|
||||
renderPanel();
|
||||
const region = screen.getByLabelText('所在区域') as HTMLSelectElement;
|
||||
const values = Array.from(region.options, (option) => option.value);
|
||||
|
||||
expect(values).toEqual([
|
||||
'all',
|
||||
'ui-interaction',
|
||||
'character',
|
||||
'scene',
|
||||
'audio',
|
||||
'document',
|
||||
'unclassified',
|
||||
'version',
|
||||
]);
|
||||
expect(Array.from(region.options, (option) => option.textContent)).toEqual([
|
||||
'全部区域',
|
||||
'UI 交互',
|
||||
'角色与对象',
|
||||
'场景与环境',
|
||||
'音频',
|
||||
'文档',
|
||||
'待归类',
|
||||
'项目版本',
|
||||
]);
|
||||
});
|
||||
|
||||
it('关键词输入把值交回宿主,且回显宿主给的值', () => {
|
||||
const onKeywordChange = vi.fn();
|
||||
renderPanel({ keyword: '按钮', onKeywordChange });
|
||||
|
||||
const input = screen.getByLabelText('查找素材') as HTMLInputElement;
|
||||
expect(input.value).toBe('按钮');
|
||||
|
||||
fireEvent.change(input, { target: { value: '主角' } });
|
||||
expect(onKeywordChange).toHaveBeenCalledWith('主角');
|
||||
});
|
||||
|
||||
it('切换区域只把选择交回宿主(区域即画布栏目,由宿主切栏目)', () => {
|
||||
const onRegionChange = vi.fn();
|
||||
renderPanel({ region: 'all', onRegionChange });
|
||||
|
||||
fireEvent.change(screen.getByLabelText('所在区域'), {
|
||||
target: { value: 'character' },
|
||||
});
|
||||
expect(onRegionChange).toHaveBeenCalledWith('character');
|
||||
});
|
||||
|
||||
it('标签是受控多选,按下状态来自宿主,点击交回标签值', () => {
|
||||
const onToggleTag = vi.fn();
|
||||
renderPanel({ activeTags: ['主页'], onToggleTag });
|
||||
|
||||
const home = screen.getByRole('button', { name: /主页/ });
|
||||
const festival = screen.getByRole('button', { name: /节日/ });
|
||||
expect(home.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(festival.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
fireEvent.click(festival);
|
||||
expect(onToggleTag).toHaveBeenCalledWith('节日');
|
||||
});
|
||||
|
||||
it('标签库为空时不渲染标签字段', () => {
|
||||
renderPanel({ tagItems: [] });
|
||||
expect(screen.queryByRole('group', { name: '自定义标签' })).toBeNull();
|
||||
});
|
||||
|
||||
it('右上角关闭键与 Escape 都关浮层,点外部也关;点触发按钮不算外部', () => {
|
||||
const onClose = vi.fn();
|
||||
const first = renderPanel({ onClose });
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭筛选' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
first.unmount();
|
||||
|
||||
const second = renderPanel({ onClose: vi.fn() });
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(second.onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 点浮层内部不关。
|
||||
fireEvent.click(screen.getByLabelText('查找素材'));
|
||||
expect(second.onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 点触发按钮不关:按钮自己负责开合,不能被判成外部点击。
|
||||
fireEvent.click(second.triggerRef.current!);
|
||||
expect(second.onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 点浮层外部才关。
|
||||
fireEvent.click(document.body);
|
||||
expect(second.onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Escape 关闭后把焦点还给触发按钮', () => {
|
||||
const { triggerRef } = renderPanel({ onClose: vi.fn() });
|
||||
const input = screen.getByLabelText('查找素材');
|
||||
input.focus();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(document.activeElement).toBe(triggerRef.current);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user