feat(工具栏): 将类型筛选器从下拉框改为自定义菜单组件

重构类型筛选器交互方式,使用自定义菜单组件替代原生下拉框,提升用户体验
- 添加菜单打开/关闭状态管理
- 实现点击外部区域或按ESC键关闭菜单
- 添加菜单项选中状态和键盘导航支持
- 优化按钮和菜单项的样式和交互效果
这个提交包含在:
Zain 2025-12-30 17:22:49 +08:00
父节点 3810c776c7
当前提交 2aedd8b697
共有 2 个文件被更改,包括 300 次插入 和 52 次删除

查看文件

@ -1,5 +1,5 @@
import { LitElement, html, css } from 'lit'; import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { FileType } from '../../types/file-selector'; import { FileType } from '../../types/file-selector';
import { I18nService } from '../../utils/i18n'; import { I18nService } from '../../utils/i18n';
@ -48,6 +48,46 @@ export class Toolbar extends LitElement {
@property({ type: Boolean }) @property({ type: Boolean })
disabled = false; disabled = false;
@state()
private typeMenuOpen = false;
private documentClickHandler: ((e: MouseEvent) => void) | null = null;
private documentKeydownHandler: ((e: KeyboardEvent) => void) | null = null;
connectedCallback() {
super.connectedCallback();
this.documentClickHandler = (e: MouseEvent) => {
if (!this.typeMenuOpen) return;
const path = e.composedPath();
if (path.includes(this)) return;
this.closeTypeMenu();
};
this.documentKeydownHandler = (e: KeyboardEvent) => {
if (!this.typeMenuOpen) return;
if (e.key !== 'Escape') return;
e.preventDefault();
this.closeTypeMenu({ focusButton: true });
};
document.addEventListener('click', this.documentClickHandler);
document.addEventListener('keydown', this.documentKeydownHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.documentClickHandler) {
document.removeEventListener('click', this.documentClickHandler);
this.documentClickHandler = null;
}
if (this.documentKeydownHandler) {
document.removeEventListener('keydown', this.documentKeydownHandler);
this.documentKeydownHandler = null;
}
}
/** /**
* Computed: checkbox state * Computed: checkbox state
* Returns 'all', 'none', or 'partial' * Returns 'all', 'none', or 'partial'
@ -84,13 +124,10 @@ export class Toolbar extends LitElement {
} }
/** /**
* Handle type filter change * Handle type filter selection
* @private * @private
*/ */
private handleTypeFilterChange(e: Event): void { private selectType(type: FileType | 'all'): void {
const select = e.target as HTMLSelectElement;
const type = select.value as FileType | 'all';
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('type-filter', { new CustomEvent('type-filter', {
detail: { type }, detail: { type },
@ -100,8 +137,65 @@ export class Toolbar extends LitElement {
); );
} }
private getTypeLabel(type: FileType | 'all'): string {
switch (type) {
case 'all':
return I18nService.t('all_types');
case 'video':
return I18nService.t('type_video');
case 'image':
return I18nService.t('type_image');
case 'audio':
return I18nService.t('type_audio');
case 'document':
return I18nService.t('type_document');
case 'other':
return I18nService.t('type_other');
}
}
private toggleTypeMenu(): void {
if (this.disabled) return;
this.typeMenuOpen = !this.typeMenuOpen;
if (this.typeMenuOpen) {
requestAnimationFrame(() => {
const selected =
this.shadowRoot?.querySelector<HTMLButtonElement>('.type-menu-item[data-selected="true"]') ||
this.shadowRoot?.querySelector<HTMLButtonElement>('.type-menu-item');
selected?.focus();
});
}
}
private closeTypeMenu(options?: { focusButton?: boolean }): void {
if (!this.typeMenuOpen) return;
this.typeMenuOpen = false;
if (options?.focusButton) {
requestAnimationFrame(() => {
const button = this.shadowRoot?.getElementById('type-filter-button') as HTMLButtonElement | null;
button?.focus();
});
}
}
private handleTypeMenuItemClick(type: FileType | 'all'): void {
if (this.disabled) return;
this.closeTypeMenu({ focusButton: true });
this.selectType(type);
}
render() { render() {
const state = this.checkboxState; const state = this.checkboxState;
const typeOptions: Array<{ value: FileType | 'all'; label: string }> = [
{ value: 'all', label: I18nService.t('all_types') },
{ value: 'video', label: I18nService.t('type_video') },
{ value: 'image', label: I18nService.t('type_image') },
{ value: 'audio', label: I18nService.t('type_audio') },
{ value: 'document', label: I18nService.t('type_document') },
{ value: 'other', label: I18nService.t('type_other') },
];
return html` return html`
<div class="toolbar"> <div class="toolbar">
@ -134,20 +228,67 @@ export class Toolbar extends LitElement {
</label> </label>
<div class="filter-group"> <div class="filter-group">
<label class="filter-label">${I18nService.t('filter_by_type')}:</label> <div class="type-select">
<select <button
class="type-filter" class="type-button"
.value=${this.typeFilter} id="type-filter-button"
@change=${this.handleTypeFilterChange} type="button"
aria-haspopup="menu"
aria-expanded=${this.typeMenuOpen ? 'true' : 'false'}
aria-label=${`${I18nService.t('filter_by_type')}: ${this.getTypeLabel(this.typeFilter)}`}
title=${`${I18nService.t('filter_by_type')}: ${this.getTypeLabel(this.typeFilter)}`}
data-active=${this.typeFilter === 'all' ? 'false' : 'true'}
?disabled=${this.disabled} ?disabled=${this.disabled}
@click=${this.toggleTypeMenu}
> >
<option value="all">${I18nService.t('all_types')}</option> <span class="sr-only">${I18nService.t('filter_by_type')}</span>
<option value="video">${I18nService.t('type_video')}</option> <svg
<option value="image">${I18nService.t('type_image')}</option> class="type-button-icon"
<option value="audio">${I18nService.t('type_audio')}</option> viewBox="0 0 24 24"
<option value="document">${I18nService.t('type_document')}</option> fill="none"
<option value="other">${I18nService.t('type_other')}</option> stroke="currentColor"
</select> stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false"
>
<polygon points="22 3 2 3 10 12 10 19 14 21 14 12 22 3"></polygon>
</svg>
<span class="type-button-text">${this.getTypeLabel(this.typeFilter)}</span>
<svg
class="type-button-chevron"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M6 8l4 4 4-4"></path>
</svg>
</button>
<div class="type-menu" role="menu" aria-label=${I18nService.t('filter_by_type')} ?hidden=${!this.typeMenuOpen}>
${typeOptions.map(({ value, label }) => {
const selected = value === this.typeFilter;
return html`
<button
class="type-menu-item"
type="button"
role="menuitemradio"
aria-checked=${selected ? 'true' : 'false'}
data-selected=${selected ? 'true' : 'false'}
@click=${() => this.handleTypeMenuItemClick(value)}
>
${label}
</button>
`;
})}
</div>
</div>
</div> </div>
</div> </div>
@ -266,38 +407,145 @@ export class Toolbar extends LitElement {
gap: 8px; gap: 8px;
} }
.filter-label { .sr-only {
font-size: 14px; position: absolute;
color: var(--cdr-text-secondary, #595959); width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; white-space: nowrap;
border: 0;
} }
.type-filter { .type-select {
padding: 6px 12px; position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.type-button {
width: auto;
height: 32px;
padding: 0 10px 0 8px;
border: 1px solid var(--cdr-border-strong, #d9d9d9); border: 1px solid var(--cdr-border-strong, #d9d9d9);
border-radius: 4px; border-radius: 6px;
font-size: 14px;
color: var(--cdr-text, #262626); color: var(--cdr-text, #262626);
background: var(--cdr-surface, #fff); background: var(--cdr-surface, #fff);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
min-width: 120px; display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
min-width: 0;
} }
.type-filter:hover:not(:disabled) { .type-button:hover:not(:disabled) {
background: var(--cdr-surface-hover, #f5f5f5);
border-color: var(--cdr-primary, #1890ff); border-color: var(--cdr-primary, #1890ff);
} }
.type-filter:focus { .type-button[data-active='true']:hover:not(:disabled) {
background: var(--cdr-selection-hover-bg, rgba(24, 144, 255, 0.12));
}
.type-button:focus-visible {
outline: none; outline: none;
border-color: #1890ff; border-color: var(--cdr-primary, #1890ff);
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
} }
.type-filter:disabled { .type-button:disabled {
color: var(--cdr-text-disabled, #bfbfbf);
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.45;
}
.type-button[data-active='true'] {
border-color: var(--cdr-primary, #1890ff);
background: var(--cdr-selection-bg, rgba(24, 144, 255, 0.08));
color: var(--cdr-primary, #1890ff);
}
.type-button-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.9;
}
.type-button-text {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 96px;
min-width: 0;
}
.type-button-chevron {
width: 14px;
height: 14px;
flex-shrink: 0;
opacity: 0.8;
}
.type-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
width: 180px;
padding: 6px;
background: var(--cdr-surface, #fff);
border: 1px solid var(--cdr-border-strong, #d9d9d9);
border-radius: 8px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
z-index: 1000;
}
.type-menu-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--cdr-text, #262626);
font-size: 13px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.type-menu-item::after {
content: '';
width: 16px;
text-align: right;
color: var(--cdr-primary, #1890ff);
font-size: 12px;
line-height: 1;
flex-shrink: 0;
}
.type-menu-item:hover {
background: var(--cdr-surface-hover, #f5f5f5);
}
.type-menu-item:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.type-menu-item[data-selected='true'] {
background: var(--cdr-selection-bg, rgba(24, 144, 255, 0.08));
color: var(--cdr-primary, #1890ff);
}
.type-menu-item[data-selected='true']::after {
content: '✓';
} }
.selected-count { .selected-count {

查看文件

@ -10,7 +10,7 @@
* 1. Edit version in package.json * 1. Edit version in package.json
* 2. Run: npm run sync:version (or automatically via prebuild/predev) * 2. Run: npm run sync:version (or automatically via prebuild/predev)
* *
* Generated at: 2025-12-30T08:23:44.405Z * Generated at: 2025-12-30T09:19:07.792Z
*/ */
/** /**