镜像自地址
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 { 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
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户