File size: 3,953 Bytes
494c9e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | import * as d3 from 'd3';
export type SettingsDropdownOption<T> = {
value: T;
html: string;
};
/** 共享 class,便于 CSS 复用,新增下拉类型时无需改样式 */
const SHARED = {
container: 'settings-dropdown',
btn: 'settings-dropdown-btn',
menu: 'settings-dropdown-menu',
option: 'settings-dropdown-option',
} as const;
export type CreateSettingsDropdownOptions<T> = {
container: d3.Selection<Element | d3.BaseType, unknown, HTMLElement, unknown>;
classPrefix: string;
options: SettingsDropdownOption<T>[];
dataAttr: string;
bodyClickNamespace: string;
onSelect: (value: T) => void;
};
export type SettingsDropdown<T> = {
updateCurrent: (value: T) => void;
dispose: () => void;
};
/**
* 创建设置菜单内使用的下拉(主题/语言等)共用 DOM 与开合逻辑
*/
export function createSettingsDropdown<T extends string>(
config: CreateSettingsDropdownOptions<T>
): SettingsDropdown<T> {
const { container, classPrefix, options, dataAttr, bodyClickNamespace, onSelect } = config;
const containerClass = `${classPrefix}-dropdown-container`;
const currentBtnClass = `${classPrefix}-current-btn`;
const menuClass = `${classPrefix}-dropdown-menu`;
const optionClass = `${classPrefix}-option`;
container.html('');
const dropdownContainer = container.append('div').attr('class', `${containerClass} ${SHARED.container}`);
const currentButton = dropdownContainer.append('button').attr('class', `${currentBtnClass} ${SHARED.btn}`).attr('type', 'button');
const dropdownMenu = dropdownContainer.append('div').attr('class', `${menuClass} ${SHARED.menu}`);
options.forEach(({ value, html }) => {
const option = dropdownMenu
.append('button')
.attr('class', `${optionClass} ${optionClass}-${value} ${SHARED.option}`)
.attr(dataAttr, value)
.attr('type', 'button')
.html(html);
option.on('click', function (event: MouseEvent) {
event.stopPropagation();
if (d3.select(this).classed('active')) return;
onSelect(d3.select(this).attr(dataAttr) as T);
closeDropdown();
});
});
const updateCurrent = (value: T) => {
const opt = options.find((o) => o.value === value);
if (opt) currentButton.html(opt.html);
dropdownMenu.selectAll(`.${optionClass}`).classed('active', function () {
return d3.select(this).attr(dataAttr) === value;
});
};
let isOpen = false;
const openDropdown = () => {
isOpen = true;
dropdownMenu.classed('open', true);
currentButton.classed('active', true);
};
const closeDropdown = () => {
isOpen = false;
dropdownMenu.classed('open', false);
currentButton.classed('active', false);
};
currentButton.on('click', (event: MouseEvent) => {
event.stopPropagation();
if (isOpen) closeDropdown();
else openDropdown();
});
// 统一处理:点击下拉容器外的任何地方都关闭(包括设置菜单的其他部分)
const bodyClickHandler = (event: MouseEvent) => {
if (!isOpen) return;
const target = event.target as HTMLElement;
const containerNode = dropdownContainer.node();
// 如果点击不在下拉容器内,就关闭(包括设置菜单的其他部分和页面其他地方)
if (containerNode && !containerNode.contains(target)) {
closeDropdown();
}
};
// 使用捕获阶段监听,确保即使有 stopPropagation 也能捕获到
setTimeout(() => {
document.addEventListener('click', bodyClickHandler, true);
}, 0);
return {
updateCurrent,
dispose: () => {
document.removeEventListener('click', bodyClickHandler, true);
container.selectAll('*').remove();
},
};
}
|