@wiajs/ui
Version:
wia ui packages
830 lines (828 loc) • 34.6 kB
JavaScript
/** @jsxImportSource @wiajs/core */ /**
* 带搜索、新增功能的自动完成输入组件
*
* 功能特性:
* 1、支持本地数据和远程数据源
* 2、支持键盘导航和鼠标操作
* 3、支持中文输入法
* 4、支持清除和搜索按钮
* 5、支持状态指示器
* 6、支持关联元素,点击不关闭下拉列表
* 7、支持新增网址按钮
* 8、限制显示数量,避免性能问题
* 9、支持键值对数据(统一[key, value]格式)
* 10、支持高亮匹配字符
* 11、支持数据缓存,避免重复查询,查询的数据与缓存数据合并去重
* 12、支持多实例,实例之间共享数据,比如 列表编辑,同一列多行使用同一数据源
* 13、支持原值显示
* 14、不被父滚动元素overflow隐藏,如限制高度的表格容器
* 15、点击查询按钮,触发查询,没有输入时按限制条数查询、返回数据
* 16、输入框输入时,按输入内容过滤显示,不触发查询
* 14、下拉列表挂载page-content,跟随input滚动,不被overflow隐藏
* 16、输入时本地过滤,空值时显示所有列表
* 17、滚动内部父元素,自动隐藏列表
* 18、改变窗口大小,自动隐藏列表
* 19、所有ac input 共用一个 list
*
* ai:
* 2025-06-12 10:00:00
以上代码是个 带搜索的自动完成输入组件,需修改,实现如下功能:
1、点击 input 输入框,或者不输入点击搜索时,显示下拉选项列表时,列表的第一个值,为外部通过 opts 传入的 value值
2、input输入框中输入字符时,不触发 search接口,只对缓存在 data中的数据,进行过滤、高亮
3、点击搜索按钮,无论输入框是否有值,都触发调用搜索接口,从传入的source参数获取数据
4、获取的数据为二维数组,子数组有两个值,类似[key, value]数据对,如果数组元素不是数组,而是value值,需将值转换为[value, value]形式的数组
5、返回的数据与缓存的data中的数据进行合并,key、value 值对不能重复(同时判断key与value)
6、下拉列表中显示key value中的value,选择value,value填入input,key存入input的 data-key 属性中
7、提供clear函数,清空缓存的 data 数据
8、提供setData函数,设置下拉数据,并显示下拉列表
9、提供addData函数,向下拉添加数据(数据不可重复),并显示下拉列表
10、下拉列表不能被父容器的 overflow 隐藏,需挂载在 传入的 page.view上,view类型为jQuery实例,位置还是在input的下面,类似select,不被其他页面元素遮挡。
*/ import { jsx as _jsx } from "@wiajs/core/jsx-runtime";
import { Event } from '@wiajs/core';
import { log as Log } from '@wiajs/util';
const log = Log({
m: 'autoComplete'
}) // 创建日志实例
;
/**
* @typedef {import('jquery')} $
* @typedef {JQuery} Dom
* @typedef {import('@wiajs/core').Page} Page
*/ /** @typedef {object} Opts
* @prop {Dom} el - 容器元素
* @prop {boolean} [status] - 状态指示器
* @prop {boolean} [search] - 显示搜索按钮
* @prop {boolean} [clear] - 显示清除按钮
* @prop {number} [maxItems] - 最大显示数量
* @prop {*[]} [data] - 初始数据(会自动转为[key, value]格式)
* @prop {string} [name] - input 名称
* @prop {string|number} [value] - 初始值(显示在列表第一个)
* @prop {string} [placeholder] - 输入框占位符
* @prop {HTMLElement[]} [refEl] - 关联元素,点击不关闭列表
* @prop {{url:string, token:string, param?:object}} [source] - 远程数据源配置
* @prop {string} [addUrl] - 新增网址
* @prop {number} [gap] - // 列表与输入框的间隙(默认4px)
*/ /** @typedef {object} Opt
* @prop {Dom} el - 容器元素
* @prop {boolean} status - 状态指示器
* @prop {boolean} search - 显示搜索按钮
* @prop {boolean} clear - 显示清除按钮
* @prop {*[]} data - 缓存数据(已转为[key, value]格式)
* @prop {string} name - input 名称
* @prop {string|number} value - 初始值
* @prop {string} placeholder - 占位符
* @prop {number} maxItems - 最大显示数量
* @prop {HTMLElement[]} refEl - 关联元素
* @prop {{url:string, token:string, param?:object}} source - 远程数据源
* @prop {string} addUrl - 新增网址
* @prop {number} gap - // 列表与输入框的间隙(默认4px)
*/ /** @type {Opt} */ const def = {
el: 'autocompelete',
status: true,
search: true,
clear: true,
/** @type {*[]} */ data: [],
/** @type {HTMLElement[]} */ refEl: [],
maxItems: 50,
source: undefined,
addUrl: undefined,
name: '',
value: undefined,
placeholder: '',
gap: 4
};
export default class Autocomplete extends Event {
/**
* 构造
* @param {Page} page 页面实例
* @param {Opts} opts 选项
*/ constructor(page, opts){
/** @type{Opt} */ const opt = {
...def,
...opts
};
super(opt, [
page
]), /** @type {[string, string][]} */ this.data = [] // 缓存数据(统一[key, value]格式)
, this.currentFocus = -1 // 当前高亮选项索引
, this.isComposing = false // 是否在中文输入过程中
, this.lastValue = '' // 上一次的值(用于防抖)
, this.optValueKv = null // 传入value转换后的[key, value]格式
, this.scrollListener = null // 滚动监听函数引用(用于解绑)
, this.listMaxHeight = 300 // 列表最大高度(与样式一致)
, this.btnTrigger = false // 内部按钮点击,不触发 blur 事件
, this.selTrigger = false // 内部选择点击,不触发 focus的showAllList 事件
, this.searchTrigger = false // 内部选择点击,不触发 focus的showAllList 事件
, this.arrowTrigger = false // 内部按键,上下按键
;
const _ = this;
_.page = page // Page实例
;
_.opt = opt;
// 处理初始值,转为[key, value]格式
_.optValueKv = opt.value !== undefined ? _.convertToKvArray(opt.value) : null;
// 处理初始数据,转为[key, value]格式并去重
_.data = _.mergeAndDeduplicateData([], opt.data);
let { el } = opt;
if (typeof el === 'string') el = $(el);
if (el?.dom) {
_.el = el;
el.dom._wiaAutocomplete = _ // 关联实例
;
// 查找page-content容器(相对定位,内部滚动)
_.pageContent = _.page.view.find('.page-content');
if (!_.pageContent.length) {
log.warn('未找到.page-content容器,默认使用page.view');
_.pageContent = _.page.view;
}
_.init();
_.bind();
// 设置输入框placehold值
if (_.optValueKv) {
_.input.val(_.optValueKv[1]);
_.input.data('key', _.optValueKv[0]);
_.lastValue = _.optValueKv[1];
}
_.upClearButton();
// 如果 param 中有 q 参数,初始化时先调用一次 search
if (opt.source?.param && 'q' in opt.source.param) {
;
(async ()=>{
try {
_.showStatus('查询中...');
await _.search(opt.source, '');
// 查询完成后显示列表
_.showAllList();
} catch (e) {
log.err(e, 'init search');
} finally{
_.hideStatus();
}
})();
}
}
}
/**
* 工具函数:将任意数据转为[key, value]格式
* @param {*} item 要转换的数据
* @returns {[string, string]} [key, value]数组
*/ convertToKvArray(item) {
if (Array.isArray(item) && item.length >= 2) {
// 已是[key, value]格式,取前两个元素并转为字符串
return [
String(item[0]),
String(item[1])
];
}
// 单个值,转为[value, value]格式
const val = String(item);
return [
val,
val
];
}
/**
* 工具函数:合并数据并去重(key和value都相同才视为重复)
* @param {[string, string][]} existingData 原有数据
* @param {*[]} newData 新数据(可单个值或数组)
* @returns {[string, string][]} 合并去重后的数据
*/ mergeAndDeduplicateData(existingData, newData) {
const _ = this;
// 处理新数据格式:如果 newData 为空或未定义,直接返回空数组
if (!newData || Array.isArray(newData) && newData.length === 0) {
return existingData;
}
const processedNewData = (Array.isArray(newData) ? newData : [
newData
]).map((item)=>_.convertToKvArray(item)).filter((kv)=>kv[0] && kv[1]) // 过滤空数据
;
// 合并原有数据和新数据
const combined = [
...existingData,
...processedNewData
];
// 用key+value作为唯一标识去重
const uniqueMap = new Map();
combined.forEach((kv)=>{
const uniqueKey = `${kv[0]}_${kv[1]}`;
if (!uniqueMap.has(uniqueKey)) {
uniqueMap.set(uniqueKey, kv);
}
});
return Array.from(uniqueMap.values());
}
init() {
const _ = this;
try {
const { el, opt } = _;
const { status, search, addUrl, clear, name, source } = opt;
const { placeholder = '请输入' } = opt;
_.wrapper = el.find('.ac-wrapper');
// 生成容器和输入框
if (!_.wrapper?.length) {
_.wrapper = $(/*#__PURE__*/ _jsx("div", {
class: "ac-wrapper",
children: /*#__PURE__*/ _jsx("input", {
type: "text",
name: name,
class: "ac-input",
placeholder: placeholder,
autocomplete: "off"
})
})).appendTo(el);
}
const { wrapper } = _;
_.input = wrapper.find('.ac-input');
// 添加清除按钮
if (clear) {
_.btnClear = $(/*#__PURE__*/ _jsx("button", {
type: "button",
class: "ac-clear",
children: /*#__PURE__*/ _jsx("i", {
class: "icon wiaicon",
children: ""
})
})).appendTo(wrapper);
}
// 添加搜索按钮
if (search && source) {
_.btnSearch = $(/*#__PURE__*/ _jsx("button", {
type: "button",
class: "ac-search",
children: /*#__PURE__*/ _jsx("i", {
class: "icon wiaicon",
children: ""
})
})).appendTo(wrapper);
} else _.btnClear.css('right', '3px');
// 添加新增按钮
if (addUrl) {
el.addClass('has-add');
_.btnAdd = $(/*#__PURE__*/ _jsx("button", {
type: "button",
class: "ac-add",
children: /*#__PURE__*/ _jsx("i", {
class: "icon wiaicon",
children: ""
})
})).appendTo(wrapper);
}
// 添加状态指示器
if (status) _.dvStatus = $(/*#__PURE__*/ _jsx("div", {
class: "ac-status"
})).appendTo(wrapper);
// 全局共享一个 list
_.dvList = _.pageContent.find('.ac-list');
// 关键修改:下拉列表挂载到page-content(相对定位,内部滚动) name={`${name}AcList`}
if (!_.dvList?.length) _.dvList = $(/*#__PURE__*/ _jsx("div", {
class: "ac-list"
})).appendTo(_.pageContent);
_.hideList() // 初始隐藏
;
} catch (e) {
log.err(e, 'init');
}
}
bind() {
const _ = this;
try {
const { el, input, opt, wrapper, data, dvList } = _;
const { source, addUrl } = opt;
// 清除按钮事件(添加mousedown标记,click执行原有逻辑)
_.btnClear?.on('mousedown', ()=>{
_.btnTrigger = true;
});
_.btnSearch?.on('mousedown', ()=>{
_.btnTrigger = true;
});
// 清除按钮事件
_.btnClear?.click(()=>{
_.input.val('');
_.input.data('key', '');
_.lastValue = '';
_.hideStatus();
_.upClearButton();
// setTimeout(() => {
// _.input.focus()
// _.showAllList()
// }, 100)
_.input.focus();
});
input.blur((ev)=>{
const { btnTrigger } = _;
// 若标记为清除按钮触发,不发射事件,且重置标记
if (btnTrigger) {
_.btnTrigger = false // 重置标记,避免影响后续blur
;
return;
}
_.hideList() // 隐藏 列表
;
_.emit('local::blur', ev);
});
// 输入框聚焦时显示所有列表
input.focus((ev)=>{
if (_.selTrigger) {
_.selTrigger = false;
return;
}
if (_.searchTrigger) {
_.searchTrigger = false;
return;
}
_.showAllList();
});
// 输入事件监听(需求2:只过滤本地数据,不触发接口)
input.input(function(ev) {
const inputValue = this.value.trim();
if (_.isComposing) return; // 中文输入过程中忽略
// 更新清除按钮状态
_.upClearButton();
// 值未变化时忽略
if (inputValue === _.lastValue) return;
_.lastValue = inputValue;
// 关键修改:空值时显示所有列表,而非隐藏
if (!inputValue.trim()) {
_.showAllList() // 显示所有列表
;
_.hideStatus();
return;
}
// await _.search(source, inputValue)
// 只过滤本地缓存数据
const filteredData = _.filter(inputValue);
_.showList(filteredData, inputValue);
});
// 中文输入法开始
input.dom.addEventListener('compositionstart', ()=>{
_.isComposing = true;
_.input.addClass('composing');
_.showStatus('输入中...');
});
// 中文输入法结束
input.dom.addEventListener('compositionend', function() {
_.isComposing = false;
_.input.removeClass('composing');
const inputValue = this.value.trim();
_.lastValue = inputValue;
_.upClearButton();
// 关键修改:空值时显示所有列表,而非隐藏
if (!inputValue) {
_.showAllList() // 显示所有列表
;
_.hideStatus();
return;
}
// await _.search(source, inputValue)
// 只过滤本地缓存数据
const filteredData = _.filter(inputValue);
_.showList(filteredData, inputValue);
_.hideStatus();
});
// 键盘事件监听
input.keydown((ev)=>{
const items = _.dvList.find('.ac-item');
if (items.length > 0) {
if (ev.key === 'ArrowDown') {
// 向下键
_.arrowTrigger = true;
ev.preventDefault();
_.currentFocus = (_.currentFocus + 1) % items.length;
_.setActive(items);
} else if (ev.key === 'ArrowUp') {
// 向上键
_.arrowTrigger = true;
ev.preventDefault();
_.currentFocus = (_.currentFocus - 1 + items.length) % items.length;
_.setActive(items);
} else if (ev.key === 'Enter') {
// 回车键
if (_.arrowTrigger) {
ev.preventDefault();
if (_.currentFocus > -1) {
items.eq(_.currentFocus).click();
}
} else _.btnSearch?.click();
}
} else _.btnSearch?.click();
});
// 点击页面其他区域关闭下拉框
// _.page.view.click(ev => {
// if (!_.el.dom.contains(ev.target) && _.opt.refEl.every(el => !el.contains(ev.target))) {
// _.hideList()
// }
// })
// 搜索按钮事件(无论是否有值都触发接口)
_.btnSearch?.click(async ()=>{
const inputValue = _.input.val().trim();
_.showStatus('查询中...');
try {
await _.search(opt.source, inputValue);
// 接口返回后显示列表
inputValue ? _.showList(_.filter(inputValue), inputValue) : _.showAllList();
_.searchTrigger = true;
input.focus();
} catch (e) {
log.err(e, 'btnSearch click');
_.dvList.html('<div class="ac-item"><i class="fas fa-exclamation-circle"></i> 查询失败,请重试</div>');
_.updateListPosition();
} finally{
_.hideStatus();
}
});
if (addUrl && _.btnAdd) {
// 新增按钮事件
_.btnAdd.click((ev)=>{
const newWin = window.open(addUrl, '_blank');
if (newWin) newWin.focus();
});
}
_.scroll();
} catch (e) {
log.err(e, 'bind');
}
}
/**
* 滚动时,隐藏列表
*/ scroll() {
const _ = this;
try {
const { input, pageContent } = _;
// 关键:监听page-content滚动事件,实现列表跟随input滚动
// _.scrollListener = () => {
// if (_.dvList.is(':visible')) _.updateListPosition()
// }
// _.pageContent.on('scroll', _.scrollListener) 监听之外的层
// 5) 滚动/缩放/软键盘:统一用 rAF 节流读取 rect
let ticking = false;
const schedule = ()=>{
if (ticking) return;
ticking = true;
requestAnimationFrame(()=>{
ticking = false;
_.hideList();
input.blur();
});
};
// 5.1 全局窗口尺寸
window.addEventListener('resize', schedule, {
passive: true
});
// 5.2 可滚动祖先:找到并监听(被 A/B/C 滚动时也能跟随)
const scrollables = new Set();
const isScrollable = (el)=>{
if (!(el instanceof Element)) return false;
const s = getComputedStyle(el);
return /(auto|scroll|overlay)/.test(s.overflow + s.overflowX + s.overflowY);
};
for(let p = input.dom.parentNode; p && p !== document; p = p.parentNode || p.host){
if (isScrollable(p)) {
if (p === pageContent.dom) break;
scrollables.add(p);
}
}
for (const s of scrollables)s.addEventListener('scroll', schedule, {
passive: true
});
} catch (e) {
log.err(e, 'scroll');
}
}
/**
* 调用搜索接口获取数据
* @param {Opt['source']} source 数据源配置
* @param {string} value 输入框值
*/ async search(source, value) {
const _ = this;
try {
if (!source?.url) throw new Error('未配置搜索接口地址');
// 显示加载状态
// _.showStatus('查询中...')
const { url, token = 'token', param = {} } = source;
const tk = $.store.get(token);
// 构建请求参数
const requestParam = {
...param,
value
};
const rs = await $.post(url, requestParam, {
'x-wia-token': tk
});
// 输入完成后再触发查询
if (rs && Array.isArray(rs)) {
// 需求4:转换数据格式为[key, value]
const processedData = rs.map((item)=>_.convertToKvArray(item));
// 合并数据并去重
_.data = _.mergeAndDeduplicateData(_.data, processedData);
// _.data = rs
// const filteredData = _.filter(value)
// _.showList(filteredData, value)
// _.hideStatus()
}
} catch (e) {
log.err(e, 'search');
}
}
focus() {
this.input.focus();
}
val() {
return this.input.val();
}
key() {
return this.input.data('key');
}
/**
* 更新清除按钮显示状态
*/ upClearButton() {
const _ = this;
if (!_.btnClear) return;
_.input.val().trim() ? _.btnClear.addClass('show') : _.btnClear.removeClass('show');
}
/**
* 过滤本地缓存数据(根据value匹配),包含 _.data 和 optValueKv 两组数据
* @param {string} input 输入值
* @returns {[string, string][]} 过滤后的[key, value]数组(去重)
*/ filter(input) {
let R = [];
const _ = this;
try {
const inputLower = input.toLowerCase();
// return _.data?.filter(([x, val]) => val.toLowerCase().includes(inputLower))
// 处理空值:将 null/undefined 转为空数组,避免过滤报错
const dataList = _.data || [];
const optValueKvList = _.optValueKv ? [
_.optValueKv
] : [];
// 统一过滤规则:val 转小写后包含输入值小写
// @ts-expect-error
const fun = ([key, val])=>{
return val ? val.toLowerCase().includes(inputLower) : false;
};
// 分别过滤两组数据
const filteredData = dataList.filter(fun);
const filteredOptValueKv = optValueKvList.filter(fun);
// 合并并去重(基于 key+value 组合去重,保留先出现的条目)
const mergedMap = new Map();
// 先放 _.data 的结果,优先级更高
filteredData.forEach((kv)=>{
const uniqueKey = `${kv[0]}_${kv[1]}`;
if (!mergedMap.has(uniqueKey)) {
mergedMap.set(uniqueKey, kv);
}
});
// 后放 optValueKv 的结果,重复的会被忽略
filteredOptValueKv.forEach((kv)=>{
const uniqueKey = `${kv[0]}_${kv[1]}`;
if (!mergedMap.has(uniqueKey)) {
mergedMap.set(uniqueKey, kv);
}
});
// 转回 [key, value] 数组格式
R = Array.from(mergedMap.values());
} catch (e) {}
return R;
}
show() {
const _ = this;
const { el } = _;
el.show();
}
hide() {
const _ = this;
const { dvList, el } = _;
el.hide();
_.hideList();
}
/**
* 显示所有列表(第一个值为传入的value)
* @param {*[]} [data] 可选传入数据(会合并到缓存)
*/ showAllList(data) {
const _ = this;
try {
// 处理传入数据,合并到显示数据中
const displayData = data ? _.mergeAndDeduplicateData(_.data, data) : [
..._.data
];
// 需求1:添加传入的value到列表第一个位置(去重)
if (_.optValueKv) {
const isDuplicate = displayData.some(([key, val])=>key === _.optValueKv[0] && val === _.optValueKv[1]);
if (!isDuplicate) displayData.unshift(_.optValueKv);
}
if (!displayData.length) {
_.dvList.html('<div class="ac-item"><i class="fas fa-info-circle"></i> 暂无数据</div>');
_.updateListPosition();
return;
}
// 限制显示数量,避免性能问题
const { maxItems } = _.opt;
const rs = displayData.slice(0, maxItems);
_.dvList.empty();
for (const [key, val] of rs){
const el = $('<div class="ac-item"></div>').text(val).data('key', key).mousedown(()=>{
_.btnTrigger = true;
}).click(()=>{
_.selTrigger = true;
_.input.val(val).data('key', key).focus();
_.lastValue = val;
_.upClearButton();
_.hideList();
}).appendTo(_.dvList);
}
// 添加超出数量提示
if (displayData.length > maxItems) {
const infoEl = $('<div class="ac-item"></div>').html(`<i class="fas fa-info-circle"></i> 显示前 ${maxItems} 条,共 ${displayData.length} 条`);
_.dvList.append(infoEl);
}
// 更新列表位置(关键)
_.updateListPosition();
_.currentFocus = -1;
} catch (e) {
log.err(e, 'showAllList');
}
}
/**
* 显示过滤后的列表(带高亮)
* @param {[string, string][]} data 过滤后的[key, value]数组
* @param {string} inputValue 输入值(用于高亮)
*/ showList(data, inputValue) {
const _ = this;
if (data.length === 0) {
_.dvList.html('<div class="ac-item"><i class="fas fa-exclamation-circle"></i>匹配失败</div>');
_.updateListPosition();
return;
}
_.dvList.empty();
// 高亮匹配字符
const regex = new RegExp(inputValue, 'gi');
// 渲染列表项(需求6:显示value,存储key,高亮匹配)
for (const [key, val] of data){
const highlightedText = val.replace(regex, (match)=>`<span class="ac-highlight">${match}</span>`);
const el = $('<div class="ac-item"></div>').html(highlightedText).data('key', key).mousedown(()=>{
_.btnTrigger = true;
}).on('click', ()=>{
_.selTrigger = true;
_.input.val(val).data('key', key).focus();
_.lastValue = val;
_.upClearButton();
_.hideList();
}).appendTo(_.dvList);
}
// 更新列表位置(关键)
_.updateListPosition();
_.currentFocus = -1;
}
/**
* 隐藏下拉列表
*/ hideList() {
this.dvList?.hide();
this.arrowTrigger = false;
}
/**
* 设置高亮选项
* @param {JQuery} items 列表项JQuery集合
*/ setActive(items) {
const _ = this;
if (!items.length) return;
// 移除所有高亮
items.removeClass('highlighted');
// 设置当前高亮
if (_.currentFocus >= 0 && _.currentFocus < items.length) {
items.eq(_.currentFocus).addClass('highlighted');
// 滚动到可见区域
items[_.currentFocus].scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
}
}
/**
* 显示状态指示器
* @param {string} text 状态文本
*/ showStatus(text) {
const _ = this;
if (!_.dvStatus) return;
_.btnClear?.removeClass('show');
_.dvStatus.text(text).addClass('show');
if (text === '查询中...') {
_.dvStatus.html(`${text}<span class="loading-spinner"></span>`);
}
}
/**
* 隐藏状态指示器
*/ hideStatus() {
const _ = this;
if (!_.dvStatus) return;
_.dvStatus.removeClass('show');
_.upClearButton();
}
/**
* 核心优化:基于page-content(相对定位)的列表位置计算
* 列表跟随input滚动,宽度一致,不被overflow隐藏
* 核心优化:自动上下适配弹出的列表位置计算
* 逻辑:优先向下弹出,下方空间不足时向上弹出(列表底部对齐input顶部)
*/ updateListPosition() {
const _ = this;
try {
// 列表最大高度(与样式一致)
const { input, dvList, pageContent, opt } = _;
let { listMaxHeight } = _;
const { gap } = opt;
if (!input || !dvList || !pageContent) return;
// debugger
// 1. 获取关键元素的位置和尺寸信息(基于视口,不受滚动影响)
const prev = {
visibility: dvList.dom.style.visibility
};
dvList.dom.style.visibility = 'hidden';
dvList.show();
const listRect = dvList.rect();
const inputRect = input.rect() // 输入框视口位置
;
const contentRect = pageContent.rect() // page-content视口位置
;
const viewportHeight = window.innerHeight // 视口高度
;
// 2. 计算page-content的滚动和内边距信息(定位基准)
const contentScrollLeft = pageContent.scrollLeft() // page-content横向滚动距离
;
const contentScrollTop = pageContent.scrollTop() // page-content纵向滚动距离
;
// const contentPaddingLeft = parseInt(pageContent.css('paddingLeft')) || 0 // page-content左内边距
// const contentPaddingTop = parseInt(pageContent.css('paddingTop')) || 0 // page-content上内边距
// 3. 计算输入框相对于page-content的基础位置(用于定位)
const inputLeft = inputRect.left - contentRect.left + contentScrollLeft;
// - contentPaddingLeft
const inputTop = inputRect.top - contentRect.top + contentScrollTop;
// - contentPaddingTop
const listHeight = Math.min(listRect.height, listMaxHeight);
// 4. 空间检测:计算上下可用空间(基于视口,判断是否足够显示列表)
const spaceBelow = viewportHeight - inputRect.bottom - gap // 输入框下方可用空间
;
const spaceAbove = inputRect.top - gap // 输入框上方可用空间
;
const needUpward = spaceBelow < listHeight && spaceAbove >= listHeight // 是否需要向上弹出
;
// 5. 计算列表最终位置(完全基于pageContent内部坐标)
const listLeft = inputLeft;
let listTop = inputTop + inputRect.height + gap // 向下弹出:列表顶部 = 输入框底部 + 间隙
;
if (needUpward) listTop = inputTop - listHeight - gap // 向上弹出:列表底部 = 输入框顶部 - 间隙
;
// 修正:防止列表超出pageContent顶部边界(极端情况兜底)
if (listTop < 0) listTop = 0;
// 防止列表超出pageContent底部边界(向下弹出时兜底)
if (!needUpward && inputRect.bottom + gap + listHeight > contentRect.height) {
const calculatedHeight = contentRect.height - inputRect.bottom - gap;
listMaxHeight = Math.max(50, calculatedHeight) // 确保最小高度为50px
;
}
// 6. 强制列表样式,确保与input对齐且跟随滚动
_.dvList.css({
left: `${listLeft}px`,
top: `${listTop}px`,
width: `${inputRect.width}px`,
maxHeight: `${listMaxHeight}px`
});
dvList.dom.style.visibility = prev.visibility;
} catch (e) {
log.err(e, 'updateListPos');
}
}
/**
* 清空缓存数据
*/ clearData() {
this.data = [];
this.hideList();
}
/**
* 设置下拉数据(覆盖原有数据)并显示列表
* @param {*[]} data 要设置的数据(单个值或数组)
*/ setData(data) {
const _ = this;
// 处理数据格式并去重
_.data = _.mergeAndDeduplicateData([], data);
_.showAllList();
}
/**
* 添加下拉数据(不重复)并显示列表
* @param {*|*[]} data 要添加的数据(单个值或数组)
*/ addData(data) {
const _ = this;
// 合并数据并去重
_.data = _.mergeAndDeduplicateData(_.data, data);
_.showAllList();
}
/**
* 组件销毁时解绑事件(避免内存泄漏)
*/ destroy() {
const _ = this;
// 移除滚动监听
if (_.pageContent && _.scrollListener) {
_.pageContent.off('scroll', _.scrollListener);
}
// 移除列表元素
if (_.dvList) _.dvList.remove();
super.destroy();
}
}