镜像自地址
https://github.com/lhly/cloud-drive-renamer.git
已同步 2026-10-20 21:57:47 +08:00
feat(工具栏): 将类型筛选器从下拉框改为自定义菜单组件
重构类型筛选器交互方式,使用自定义菜单组件替代原生下拉框,提升用户体验 - 添加菜单打开/关闭状态管理 - 实现点击外部区域或按ESC键关闭菜单 - 添加菜单项选中状态和键盘导航支持 - 优化按钮和菜单项的样式和交互效果
这个提交包含在:
父节点
3810c776c7
当前提交
2aedd8b697
@ -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,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() {
|
||||
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">
|
||||
@ -134,20 +228,67 @@ export class Toolbar extends LitElement {
|
||||
</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}
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@ -266,38 +407,145 @@ export class Toolbar extends LitElement {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 14px;
|
||||
color: var(--cdr-text-secondary, #595959);
|
||||
.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-filter {
|
||||
padding: 6px 12px;
|
||||
.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: 4px;
|
||||
font-size: 14px;
|
||||
border-radius: 6px;
|
||||
color: var(--cdr-text, #262626);
|
||||
background: var(--cdr-surface, #fff);
|
||||
cursor: pointer;
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
border-color: #1890ff;
|
||||
border-color: var(--cdr-primary, #1890ff);
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.type-filter:disabled {
|
||||
color: var(--cdr-text-disabled, #bfbfbf);
|
||||
.type-button:disabled {
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户