添加折叠面板

This commit is contained in:
yuding
2025-12-22 15:39:58 +08:00
parent 005535a26d
commit ed0414c75b
29 changed files with 2759 additions and 1416 deletions

View File

@@ -0,0 +1,108 @@
.bim-tab {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: transparent;
color: var(--bim-tab-text, #e6e6e6);
}
.bim-tab__nav {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 4px 0;
background: transparent;
}
.bim-tab__item {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 0;
border: none;
border-radius: 0;
background: transparent;
color: var(--bim-tab-text, #e6e6e6);
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease;
font-size: 14px;
border-bottom: 2px solid transparent;
}
.bim-tab__item:hover:not(.is-disabled):not(.is-active) {
color: var(--bim-tab-text, #e6e6e6);
border-bottom-color: var(--bim-tab-border, rgba(255, 255, 255, 0.15));
}
.bim-tab__item.is-active {
color: var(--bim-tab-text-active, #4da3ff);
border-bottom-color: var(--bim-tab-text-active, #4da3ff);
}
.bim-tab__item.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bim-tab__icon {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--bim-tab-icon, currentColor);
}
.bim-tab__icon svg {
width: 100%;
height: 100%;
fill: currentColor;
}
.bim-tab__title {
white-space: nowrap;
}
.bim-tab__content {
flex: 1;
display: flex;
position: relative;
min-height: 0; /* 防止撑开导致外层滚动 */
overflow: hidden; /* 限制滚动只在内部面板 */
}
.bim-tab__panel {
display: none;
width: 100%;
height: 100%;
flex: 1;
}
.bim-tab__panel.is-active {
display: flex;
flex-direction: column;
height: 100%;
}
/* 构件树弹窗内的内容布局tab+搜索固定,树区域滚动 */
.construct-tab__container {
height: 100%;
display: flex;
flex-direction: column;
}
.construct-tab__panel-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* 允许内部滚动 */
overflow: hidden;
}
.construct-tab__panel-content .bim-tree {
flex: 1;
}

254
src/components/tab/index.ts Normal file
View File

@@ -0,0 +1,254 @@
import { IBimComponent } from '../../types/component';
import { localeManager, t } from '../../services/locale';
import { themeManager } from '../../services/theme';
import type { ThemeConfig } from '../../themes/types';
import type { TabItem, TabOptions } from './index.type';
import './index.css';
/**
* 简单标签页组件(固定标签,不支持运行时增删)
* - 仅处理标签头部与内容切换
* - 主题从 ThemeManager 获取,不在配置中传入
* - 文案通过 t() 翻译,支持传原文直接展示
*/
export class BimTab implements IBimComponent {
/** 组件根节点 */
public element: HTMLElement;
/** 头部容器 */
private navElement: HTMLElement;
/** 内容容器 */
private contentElement: HTMLElement;
/** 业务配置 */
private options: TabOptions;
/** 当前激活的标签 id */
private activeId: string | null;
/** id -> TabItem */
private tabMap: Map<string, TabItem> = new Map();
/** id -> 内容容器 */
private panelMap: Map<string, HTMLElement> = new Map();
/** 主题/语言订阅解除函数 */
private unsubscribeLocale: (() => void) | null = null;
private unsubscribeTheme: (() => void) | null = null;
/** 头部点击事件处理引用(便于销毁时解绑) */
private navClickHandler: ((e: MouseEvent) => void) | null = null;
constructor(options: TabOptions) {
this.options = options;
this.activeId = options.activeId || (options.tabs[0]?.id ?? null);
// 预置 tabMap方便后续查找
options.tabs.forEach((tab) => this.tabMap.set(tab.id, tab));
// 构建基础 DOM 结构
this.element = document.createElement('div');
this.element.className = 'bim-tab';
this.navElement = document.createElement('div');
this.navElement.className = 'bim-tab__nav';
this.navElement.setAttribute('role', 'tablist');
this.element.appendChild(this.navElement);
this.contentElement = document.createElement('div');
this.contentElement.className = 'bim-tab__content';
this.element.appendChild(this.contentElement);
// 挂载到容器
this.options.container.appendChild(this.element);
}
/**
* 初始化组件
*/
public init(): void {
this.renderNav();
this.renderPanels();
// 初始化文案与主题
this.setLocales();
this.setTheme(themeManager.getTheme());
// 订阅语言、主题变化
this.unsubscribeLocale = localeManager.subscribe(() => this.setLocales());
this.unsubscribeTheme = themeManager.subscribe((theme) => this.setTheme(theme));
}
/**
* 渲染头部标签
*/
private renderNav(): void {
this.navElement.innerHTML = '';
this.navClickHandler = (event: MouseEvent) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>('.bim-tab__item');
if (!target) return;
const tabId = target.dataset.id;
if (!tabId) return;
const tab = this.tabMap.get(tabId);
if (tab?.disabled) return;
this.activateTab(tabId);
};
this.navElement.addEventListener('click', this.navClickHandler);
this.options.tabs.forEach((tab) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bim-tab__item';
btn.dataset.id = tab.id;
btn.setAttribute('role', 'tab');
btn.id = `tab-${tab.id}`;
btn.setAttribute('aria-selected', `${tab.id === this.activeId}`);
if (tab.disabled) {
btn.disabled = true;
btn.setAttribute('aria-disabled', 'true');
btn.classList.add('is-disabled');
}
// 图标
if (tab.icon) {
const iconEl = document.createElement('span');
iconEl.className = 'bim-tab__icon';
iconEl.innerHTML = tab.icon;
btn.appendChild(iconEl);
}
const titleEl = document.createElement('span');
titleEl.className = 'bim-tab__title';
titleEl.textContent = this.resolveTitle(tab.title);
btn.appendChild(titleEl);
if (tab.id === this.activeId) {
btn.classList.add('is-active');
}
this.navElement.appendChild(btn);
});
}
/**
* 渲染内容面板
*/
private renderPanels(): void {
this.contentElement.innerHTML = '';
this.panelMap.clear();
this.options.tabs.forEach((tab) => {
const panel = document.createElement('div');
panel.className = 'bim-tab__panel';
panel.dataset.id = tab.id;
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', `tab-${tab.id}`);
if (tab.content instanceof HTMLElement) {
panel.appendChild(tab.content);
} else if (typeof tab.content === 'string') {
panel.innerHTML = tab.content;
}
if (tab.id === this.activeId) {
panel.classList.add('is-active');
} else {
panel.style.display = 'none';
}
this.panelMap.set(tab.id, panel);
this.contentElement.appendChild(panel);
});
}
/**
* 激活指定标签
* @param tabId 目标标签 id
*/
public activateTab(tabId: string): void {
if (this.activeId === tabId) return;
const targetTab = this.tabMap.get(tabId);
if (!targetTab || targetTab.disabled) return;
this.activeId = tabId;
// 更新头部状态
const buttons = this.navElement.querySelectorAll<HTMLButtonElement>('.bim-tab__item');
buttons.forEach((btn) => {
const isActive = btn.dataset.id === tabId;
btn.classList.toggle('is-active', isActive);
btn.setAttribute('aria-selected', `${isActive}`);
});
// 更新面板显示
this.panelMap.forEach((panel, id) => {
const isActive = id === tabId;
panel.classList.toggle('is-active', isActive);
panel.style.display = isActive ? 'block' : 'none';
});
if (this.options.onChange) {
this.options.onChange(tabId, targetTab);
}
}
/**
* 应用主题
*/
public setTheme(theme: ThemeConfig): void {
const style = this.element.style;
style.setProperty('--bim-tab-bg', theme.panelBackground);
style.setProperty('--bim-tab-nav-bg', theme.panelBackground);
style.setProperty('--bim-tab-text', theme.textPrimary);
style.setProperty('--bim-tab-text-secondary', theme.textSecondary);
style.setProperty('--bim-tab-text-active', theme.primary);
style.setProperty('--bim-tab-border', theme.border);
style.setProperty('--bim-tab-hover-bg', theme.componentHover);
style.setProperty('--bim-tab-active-bg', theme.componentActive);
style.setProperty('--bim-tab-icon', theme.icon);
}
/**
* 应用当前语言文案
*/
public setLocales(): void {
const buttons = this.navElement.querySelectorAll<HTMLButtonElement>('.bim-tab__item');
buttons.forEach((btn) => {
const id = btn.dataset.id;
if (!id) return;
const tab = this.tabMap.get(id);
if (!tab) return;
const titleEl = btn.querySelector<HTMLElement>('.bim-tab__title');
if (titleEl) {
titleEl.textContent = this.resolveTitle(tab.title);
}
});
}
/**
* 清理资源
*/
public destroy(): void {
if (this.navClickHandler) {
this.navElement.removeEventListener('click', this.navClickHandler);
this.navClickHandler = null;
}
if (this.unsubscribeLocale) {
this.unsubscribeLocale();
this.unsubscribeLocale = null;
}
if (this.unsubscribeTheme) {
this.unsubscribeTheme();
this.unsubscribeTheme = null;
}
this.panelMap.clear();
this.tabMap.clear();
this.element.remove();
}
/**
* 工具:解析标题(优先翻译,不存在则回退原值)
*/
private resolveTitle(title: string): string {
try {
const translated = t(title);
return translated || title;
} catch (err) {
// 翻译失败时使用原值
return title;
}
}
}

View File

@@ -0,0 +1,37 @@
/**
* 标签项定义TabItem
* 用于描述单个标签页的基础信息和内容。
*/
export interface TabItem {
/** 唯一标识 */
id: string;
/** 标题文案或翻译键,渲染时统一走 t() */
title: string;
/** 是否禁用 */
disabled?: boolean;
/** 可选图标,支持内联 SVG / HTML 字符串 */
icon?: string;
/** 可选的内容区域,支持 HTMLElement 或 HTML 字符串 */
content?: HTMLElement | string;
/** 业务侧自定义附加数据 */
meta?: Record<string, unknown>;
}
/**
* Tab 组件初始化参数
*/
export interface TabOptions {
/** 挂载容器 */
container: HTMLElement;
/** 预设的固定标签列表(不支持运行期增删) */
tabs: TabItem[];
/** 初始激活的标签 id默认使用首个标签 */
activeId?: string;
/**
* 切换回调
* @param tabId 当前激活的标签 id
* @param tab 当前激活的标签对象
*/
onChange?: (tabId: string, tab: TabItem | undefined) => void;
}