iter(g290): 抽出 trackShift 并接到下拉弹层
This commit is contained in:
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "ui-arch-registry-index/0.1",
|
"$schema": "ui-arch-registry-index/0.1",
|
||||||
"generatedAt": "2026-10-26T15:18:47.384Z",
|
"generatedAt": "2026-10-26T15:19:50.061Z",
|
||||||
"repoRoot": ".",
|
"repoRoot": ".",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
@@ -808,7 +808,8 @@
|
|||||||
"searchable: true 时打开即聚焦搜索框,输入实时过滤;↑↓/Home/End 只在可见项间移动,无命中显示「无匹配选项」",
|
"searchable: true 时打开即聚焦搜索框,输入实时过滤;↑↓/Home/End 只在可见项间移动,无命中显示「无匹配选项」",
|
||||||
"需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价",
|
"需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价",
|
||||||
"候选多时给选项加 group:相邻同组合成一段、组首插灰色小标题(段间有分隔线),过滤后整组无命中时标题一起隐藏——搜索路径与键盘 ↑↓ 仍只走真实选项",
|
"候选多时给选项加 group:相邻同组合成一段、组首插灰色小标题(段间有分隔线),过滤后整组无命中时标题一起隐藏——搜索路径与键盘 ↑↓ 仍只走真实选项",
|
||||||
"没有可见 label 的场合(工具栏、分页条)传 ariaLabel:触发器只显示当前值文案,读屏需要「这个下拉在选什么"
|
"没有可见 label 的场合(工具栏、分页条)传 ariaLabel:触发器只显示当前值文案,读屏需要「这个下拉在选什么",
|
||||||
|
"弹层展开期间滚动页面或任意滚动容器,会按视口重算上下翻转(与 tooltip 共用 position.ts 的 trackShift);收起即摘监听"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1324,7 +1325,7 @@
|
|||||||
"触摸屏没有 hover:手指按下即出场,抬手后停留 1.2 秒再收起,免得手指正挡着就消失",
|
"触摸屏没有 hover:手指按下即出场,抬手后停留 1.2 秒再收起,免得手指正挡着就消失",
|
||||||
"气泡设了 pointer-events: none,不会挡住下面的点击;aria-describedby 已关联,读屏能念出提示文案",
|
"气泡设了 pointer-events: none,不会挡住下面的点击;aria-describedby 已关联,读屏能念出提示文案",
|
||||||
"方位支持上下左右四向:贴边或工具栏竖排时用 left / right,出场时按该方向剩余空间自动翻到对侧",
|
"方位支持上下左右四向:贴边或工具栏竖排时用 left / right,出场时按该方向剩余空间自动翻到对侧",
|
||||||
"出场期间滚动页面或任意滚动容器,气泡会按视口重算上下翻转(rAF 节流);收起后监听立即摘掉,不在关闭态空转"
|
"出场期间滚动页面或任意滚动容器,气泡会按视口重算上下翻转(rAF 节流,与 select 等浮层共用同一套 trackShift);收起后监听立即摘掉,不在关闭态空转"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
# @ui-arch 组件库 Agent 文档(无图形版)
|
# @ui-arch 组件库 Agent 文档(无图形版)
|
||||||
|
|
||||||
> 自动生成于 2026-10-26T15:18:47.384Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
> 自动生成于 2026-10-26T15:19:50.061Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
||||||
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
|
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
|
||||||
|
|
||||||
## 快速上手
|
## 快速上手
|
||||||
@@ -317,6 +317,7 @@ const city = signal(''); select({ options: [{ value: 'bj', label: '北京' }], v
|
|||||||
7. 需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价
|
7. 需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价
|
||||||
8. 候选多时给选项加 group:相邻同组合成一段、组首插灰色小标题(段间有分隔线),过滤后整组无命中时标题一起隐藏——搜索路径与键盘 ↑↓ 仍只走真实选项
|
8. 候选多时给选项加 group:相邻同组合成一段、组首插灰色小标题(段间有分隔线),过滤后整组无命中时标题一起隐藏——搜索路径与键盘 ↑↓ 仍只走真实选项
|
||||||
9. 没有可见 label 的场合(工具栏、分页条)传 ariaLabel:触发器只显示当前值文案,读屏需要「这个下拉在选什么
|
9. 没有可见 label 的场合(工具栏、分页条)传 ariaLabel:触发器只显示当前值文案,读屏需要「这个下拉在选什么
|
||||||
|
10. 弹层展开期间滚动页面或任意滚动容器,会按视口重算上下翻转(与 tooltip 共用 position.ts 的 trackShift);收起即摘监听
|
||||||
|
|
||||||
现场演示:apps/docs/demo.html#sec-form
|
现场演示:apps/docs/demo.html#sec-form
|
||||||
|
|
||||||
@@ -630,7 +631,7 @@ tooltip({ content: '保存后立即生效' }, button({ label: '保存', onClick:
|
|||||||
3. 触摸屏没有 hover:手指按下即出场,抬手后停留 1.2 秒再收起,免得手指正挡着就消失
|
3. 触摸屏没有 hover:手指按下即出场,抬手后停留 1.2 秒再收起,免得手指正挡着就消失
|
||||||
4. 气泡设了 pointer-events: none,不会挡住下面的点击;aria-describedby 已关联,读屏能念出提示文案
|
4. 气泡设了 pointer-events: none,不会挡住下面的点击;aria-describedby 已关联,读屏能念出提示文案
|
||||||
5. 方位支持上下左右四向:贴边或工具栏竖排时用 left / right,出场时按该方向剩余空间自动翻到对侧
|
5. 方位支持上下左右四向:贴边或工具栏竖排时用 left / right,出场时按该方向剩余空间自动翻到对侧
|
||||||
6. 出场期间滚动页面或任意滚动容器,气泡会按视口重算上下翻转(rAF 节流);收起后监听立即摘掉,不在关闭态空转
|
6. 出场期间滚动页面或任意滚动容器,气泡会按视口重算上下翻转(rAF 节流,与 select 等浮层共用同一套 trackShift);收起后监听立即摘掉,不在关闭态空转
|
||||||
|
|
||||||
现场演示:apps/docs/demo.html#sec-tooltip
|
现场演示:apps/docs/demo.html#sec-tooltip
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,22 @@ export function flipSide(trigger: HTMLElement, panel: HTMLElement, preferred: 't
|
|||||||
if (preferred === 'left') return left >= w + GAP || left >= right ? 'left' : 'right'; // 对称判定
|
if (preferred === 'left') return left >= w + GAP || left >= right ? 'left' : 'right'; // 对称判定
|
||||||
return right >= w + GAP || right >= left ? 'right' : 'left';
|
return right >= w + GAP || right >= left ? 'right' : 'left';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*** 浮层出场期间的位移跟随:滚动 / 缩放时回调重算(rAF 节流);返回摘除函数,收起时调用 ***/
|
||||||
|
export function trackShift(onShift: () => void): () => void {
|
||||||
|
let raf = 0; // rAF 节流句柄:滚动事件高频,一帧最多重算一次
|
||||||
|
const run = (): void => {
|
||||||
|
if (raf) return;
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
raf = 0;
|
||||||
|
onShift();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', run, true); // 捕获阶段:滚动容器的 scroll 不冒泡,只有捕获收得到
|
||||||
|
window.addEventListener('resize', run);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', run, true);
|
||||||
|
window.removeEventListener('resize', run);
|
||||||
|
if (raf) cancelAnimationFrame(raf); // 摘除时撤销待执行帧,避免对已关闭的浮层重算
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { effect, h, signal } from '@ui-arch/frame';
|
import { effect, h, signal } from '@ui-arch/frame';
|
||||||
import type { SelectProps } from './types.js';
|
import type { SelectProps } from './types.js';
|
||||||
import { flip } from './position.js';
|
import { flip, trackShift } from './position.js';
|
||||||
import { bindOutside, pushOverlay } from './overlay.js';
|
import { bindOutside, pushOverlay } from './overlay.js';
|
||||||
|
|
||||||
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;searchable 时弹层顶部带搜索框,支持 ↑↓/Home/End 导航与 Esc 关闭 ***/
|
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;searchable 时弹层顶部带搜索框,支持 ↑↓/Home/End 导航与 Esc 关闭 ***/
|
||||||
@@ -165,6 +165,15 @@ export function select(props: SelectProps): HTMLElement {
|
|||||||
releaseEsc = null;
|
releaseEsc = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 每次打开重算方位:空间不足自动翻转
|
let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转)
|
||||||
|
effect(() => {
|
||||||
|
if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义
|
||||||
|
releaseShift?.();
|
||||||
|
releaseShift = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
place(flip(trigger, pop, 'bottom')); // 打开先算一次:空间不足自动翻转
|
||||||
|
releaseShift ??= trackShift(() => place(flip(trigger, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变
|
||||||
|
});
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { effect, h, signal } from '@ui-arch/frame';
|
import { effect, h, signal } from '@ui-arch/frame';
|
||||||
import type { TooltipProps } from './types.js';
|
import type { TooltipProps } from './types.js';
|
||||||
import { flipSide } from './position.js';
|
import { flipSide, trackShift } from './position.js';
|
||||||
|
|
||||||
let seq = 0; // 全局自增序号:保证每个气泡 id 唯一,供 aria-describedby 关联
|
let seq = 0; // 全局自增序号:保证每个气泡 id 唯一,供 aria-describedby 关联
|
||||||
|
|
||||||
@@ -57,30 +57,10 @@ export function tooltip(props: TooltipProps, trigger: HTMLElement): HTMLElement
|
|||||||
);
|
);
|
||||||
const root = h('span', { class: 'ua-tip-wrap' }, trigger, bubble);
|
const root = h('span', { class: 'ua-tip-wrap' }, trigger, bubble);
|
||||||
|
|
||||||
// —— 出场期间的位置跟随:气泡本身是 wrap 内的绝对定位(滚动时跟着走),但翻转判定看的是视口,
|
// —— 出场期间的位置跟随:气泡是 wrap 内的绝对定位(滚动时跟着走),但翻转判定看的是视口,
|
||||||
// 页面或任意滚动容器一滚就可能变成「朝上放不下」——所以出场时挂监听,收起时摘掉(不在关闭态空转) ——
|
// 页面或任意滚动容器一滚就可能变成「朝上放不下」——出场时挂监听,收起时摘掉 ——
|
||||||
let bound = false; // 监听是否已挂:重复出场不重复绑定
|
let releaseShift: (() => void) | null = null; // 位移跟随的释放函数
|
||||||
let raf = 0; // rAF 节流句柄:滚动事件高频,一帧最多重算一次
|
|
||||||
const reflow = (): void => place(flipSide(trigger, bubble, props.placement ?? 'top'));
|
const reflow = (): void => place(flipSide(trigger, bubble, props.placement ?? 'top'));
|
||||||
const onShift = (): void => {
|
|
||||||
if (raf) return;
|
|
||||||
raf = requestAnimationFrame(() => {
|
|
||||||
raf = 0;
|
|
||||||
reflow();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const bind = (): void => {
|
|
||||||
if (bound) return;
|
|
||||||
bound = true;
|
|
||||||
window.addEventListener('scroll', onShift, true); // 捕获阶段:滚动容器的 scroll 不冒泡,只有捕获能收到
|
|
||||||
window.addEventListener('resize', onShift);
|
|
||||||
};
|
|
||||||
const unbind = (): void => {
|
|
||||||
if (!bound) return;
|
|
||||||
bound = false;
|
|
||||||
window.removeEventListener('scroll', onShift, true);
|
|
||||||
window.removeEventListener('resize', onShift);
|
|
||||||
};
|
|
||||||
|
|
||||||
root.addEventListener('pointerenter', (e: PointerEvent) => { if (e.pointerType !== 'touch') show(); }); // 鼠标进入(触摸交给下面的点按)
|
root.addEventListener('pointerenter', (e: PointerEvent) => { if (e.pointerType !== 'touch') show(); }); // 鼠标进入(触摸交给下面的点按)
|
||||||
root.addEventListener('pointerleave', (e: PointerEvent) => { if (e.pointerType !== 'touch') hide(); }); // 鼠标离开
|
root.addEventListener('pointerleave', (e: PointerEvent) => { if (e.pointerType !== 'touch') hide(); }); // 鼠标离开
|
||||||
@@ -90,9 +70,13 @@ export function tooltip(props: TooltipProps, trigger: HTMLElement): HTMLElement
|
|||||||
root.addEventListener('pointerdown', (e: PointerEvent) => { if (e.pointerType === 'touch') show(); });
|
root.addEventListener('pointerdown', (e: PointerEvent) => { if (e.pointerType === 'touch') show(); });
|
||||||
root.addEventListener('pointerup', (e: PointerEvent) => { if (e.pointerType === 'touch') hideLater(); });
|
root.addEventListener('pointerup', (e: PointerEvent) => { if (e.pointerType === 'touch') hideLater(); });
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (!open()) return unbind(); // 收起即摘监听:气泡不在,重算方位没有意义
|
if (!open()) { // 收起即摘监听:气泡不在,重算方位没有意义
|
||||||
place(flipSide(trigger, bubble, props.placement ?? 'top')); // 出场先算一次:空间不足自动翻转
|
releaseShift?.();
|
||||||
bind(); // 出场期间跟着滚动 / 缩放重算:wrap 定位不会飘,但「视口里还放得下吗」会过期
|
releaseShift = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reflow(); // 出场先算一次:空间不足自动翻转
|
||||||
|
releaseShift ??= trackShift(reflow); // 出场期间跟着滚动 / 缩放重算:视口空间会变
|
||||||
});
|
});
|
||||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') { e.stopPropagation(); hide(); } // Esc 立即收起且不影响外层弹层
|
if (e.key === 'Escape') { e.stopPropagation(); hide(); } // Esc 立即收起且不影响外层弹层
|
||||||
|
|||||||
@@ -679,7 +679,8 @@
|
|||||||
"searchable: true 时打开即聚焦搜索框,输入实时过滤;↑↓/Home/End 只在可见项间移动,无命中显示「无匹配选项」",
|
"searchable: true 时打开即聚焦搜索框,输入实时过滤;↑↓/Home/End 只在可见项间移动,无命中显示「无匹配选项」",
|
||||||
"需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价",
|
"需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价",
|
||||||
"候选多时给选项加 group:相邻同组合成一段、组首插灰色小标题(段间有分隔线),过滤后整组无命中时标题一起隐藏——搜索路径与键盘 ↑↓ 仍只走真实选项",
|
"候选多时给选项加 group:相邻同组合成一段、组首插灰色小标题(段间有分隔线),过滤后整组无命中时标题一起隐藏——搜索路径与键盘 ↑↓ 仍只走真实选项",
|
||||||
"没有可见 label 的场合(工具栏、分页条)传 ariaLabel:触发器只显示当前值文案,读屏需要「这个下拉在选什么"
|
"没有可见 label 的场合(工具栏、分页条)传 ariaLabel:触发器只显示当前值文案,读屏需要「这个下拉在选什么",
|
||||||
|
"弹层展开期间滚动页面或任意滚动容器,会按视口重算上下翻转(与 tooltip 共用 position.ts 的 trackShift);收起即摘监听"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1195,7 +1196,7 @@
|
|||||||
"触摸屏没有 hover:手指按下即出场,抬手后停留 1.2 秒再收起,免得手指正挡着就消失",
|
"触摸屏没有 hover:手指按下即出场,抬手后停留 1.2 秒再收起,免得手指正挡着就消失",
|
||||||
"气泡设了 pointer-events: none,不会挡住下面的点击;aria-describedby 已关联,读屏能念出提示文案",
|
"气泡设了 pointer-events: none,不会挡住下面的点击;aria-describedby 已关联,读屏能念出提示文案",
|
||||||
"方位支持上下左右四向:贴边或工具栏竖排时用 left / right,出场时按该方向剩余空间自动翻到对侧",
|
"方位支持上下左右四向:贴边或工具栏竖排时用 left / right,出场时按该方向剩余空间自动翻到对侧",
|
||||||
"出场期间滚动页面或任意滚动容器,气泡会按视口重算上下翻转(rAF 节流);收起后监听立即摘掉,不在关闭态空转"
|
"出场期间滚动页面或任意滚动容器,气泡会按视口重算上下翻转(rAF 节流,与 select 等浮层共用同一套 trackShift);收起后监听立即摘掉,不在关闭态空转"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user