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 { customElement, property } from 'lit/decorators.js';
import { customElement, property, state } from 'lit/decorators.js';
import { FileType } from '../../types/file-selector';
import { I18nService } from '../../utils/i18n';
@ -48,6 +48,46 @@ export class Toolbar extends LitElement {
@property({ type: Boolean })
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
* Returns 'all', 'none', or 'partial'
@ -84,13 +124,10 @@ export class Toolbar extends LitElement {
}
/**
* Handle type filter change
* Handle type filter selection
* @private
*/
private handleTypeFilterChange(e: Event): void {
const select = e.target as HTMLSelectElement;
const type = select.value as FileType | 'all';
private selectType(type: FileType | 'all'): void {
this.dispatchEvent(
new CustomEvent('type-filter', {
detail: { type },
@ -100,14 +137,71 @@ 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() {
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`
<div class="toolbar">
<div class="toolbar-left">
<label
class="checkbox-label ${this.disabled ? 'disabled' : ''}"
<div class="toolbar-left">
<label
class="checkbox-label ${this.disabled ? 'disabled' : ''}"
title=${state === 'all'
? I18nService.t('deselect_all')
: I18nService.t('select_all')}
@ -131,23 +225,70 @@ export class Toolbar extends LitElement {
: ''}
</div>
<span class="checkbox-text">${I18nService.t('select_all_checkbox')}</span>
</label>
</label>
<div class="filter-group">
<label class="filter-label">${I18nService.t('filter_by_type')}:</label>
<select
class="type-filter"
.value=${this.typeFilter}
@change=${this.handleTypeFilterChange}
?disabled=${this.disabled}
>
<option value="all">${I18nService.t('all_types')}</option>
<option value="video">${I18nService.t('type_video')}</option>
<option value="image">${I18nService.t('type_image')}</option>
<option value="audio">${I18nService.t('type_audio')}</option>
<option value="document">${I18nService.t('type_document')}</option>
<option value="other">${I18nService.t('type_other')}</option>
</select>
<div class="filter-group">
<div class="type-select">
<button
class="type-button"
id="type-filter-button"
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}
@click=${this.toggleTypeMenu}
>
<span class="sr-only">${I18nService.t('filter_by_type')}</span>
<svg
class="type-button-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
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>
@ -260,44 +401,151 @@ export class Toolbar extends LitElement {
color: var(--cdr-text-disabled, #bfbfbf);
}
.filter-group {
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.type-select {
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-radius: 6px;
color: var(--cdr-text, #262626);
background: var(--cdr-surface, #fff);
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
min-width: 0;
}
.type-button:hover:not(:disabled) {
background: var(--cdr-surface-hover, #f5f5f5);
border-color: var(--cdr-primary, #1890ff);
}
.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;
border-color: var(--cdr-primary, #1890ff);
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.type-button:disabled {
cursor: not-allowed;
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;
gap: 8px;
}
.filter-label {
font-size: 14px;
color: var(--cdr-text-secondary, #595959);
white-space: nowrap;
}
.type-filter {
padding: 6px 12px;
border: 1px solid var(--cdr-border-strong, #d9d9d9);
border-radius: 4px;
font-size: 14px;
justify-content: space-between;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--cdr-text, #262626);
background: var(--cdr-surface, #fff);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
min-width: 120px;
transition: background 0.2s, color 0.2s;
}
.type-filter:hover:not(:disabled) {
border-color: var(--cdr-primary, #1890ff);
.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-filter:focus {
.type-menu-item:hover {
background: var(--cdr-surface-hover, #f5f5f5);
}
.type-menu-item:focus-visible {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.type-filter:disabled {
color: var(--cdr-text-disabled, #bfbfbf);
cursor: not-allowed;
opacity: 0.6;
.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 {

查看文件

@ -10,7 +10,7 @@
* 1. Edit version in package.json
* 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
*/
/**