Files
UI/packages/skin/ui/src/accordion.ts
T
2026-10-26 12:00:19 +08:00

76 lines
3.2 KiB
TypeScript

import { h, signal } from '@ui-arch/frame';
import type { AccordionItem, AccordionProps } from './types.js';
/*** 折叠面板:标题按钮 + grid 行高过渡;单开(手风琴)或多开,展开态由 active 信号驱动或内部持有 ***/
export function accordion(props: AccordionProps): HTMLElement {
// —— 状态集中声明 ——
const internal = signal<string[]>([]); // 内部展开集合(未传 active 时使用)
const opened = (v: string): boolean => (props.active ? props.active() === v : internal().includes(v));
const toggle = (item: AccordionItem): void => {
if (props.active) {
const next = props.active() === item.value ? '' : item.value; // 单开:再点收起
props.active(next);
props.onChange?.(next);
return;
}
const cur = internal();
const next = cur.includes(item.value)
? cur.filter((v) => v !== item.value)
: props.multiple
? [...cur, item.value]
: [item.value];
internal(next);
props.onChange?.(next[0] ?? '');
};
// —— 面板:标题为原生 button(键盘可达),内容包一层 overflow 容器供过渡裁剪 ——
const rows = props.items.map((item, i) =>
h(
'div',
{ class: () => `ua-acc-item${opened(item.value) ? ' ua-open' : ''}` },
h(
'button',
{
type: 'button',
class: 'ua-acc-head',
id: `ua-acc-h-${i}`,
'aria-expanded': () => String(opened(item.value)),
'aria-controls': `ua-acc-b-${i}`,
disabled: item.disabled ?? false, // 布尔透传即得禁用态
onClick: () => toggle(item),
},
h('span', { class: 'ua-acc-title' }, item.title),
h('span', { class: 'ua-acc-caret' }, '▸'),
),
h(
'div',
{ class: 'ua-acc-body', id: `ua-acc-b-${i}`, role: 'region', 'aria-labelledby': `ua-acc-h-${i}` },
h('div', { class: 'ua-acc-inner' }, item.content),
),
),
);
const root = h('div', { class: 'ua-accordion' }, ...rows);
// —— 键盘导航:↑↓ 行间环形移动、Home/End 首尾直达,均跳过禁用行(对齐 tabs/tree 约定) ——
const heads = (): HTMLButtonElement[] => rows.map((r) => r.firstElementChild as HTMLButtonElement); // 各行标题按钮
const focusFrom = (start: number, dir: 1 | -1): void => {
const len = heads().length;
for (let n = 1; n <= len; n++) {
const cand = heads()[(((start + dir * n) % len) + len) % len]; // 环形取候选:越界回绕保证落在 [0, len)
if (!cand.disabled) { cand.focus(); return; } // 跳过禁用行,绕满一圈即止
}
};
root.addEventListener('keydown', (e: KeyboardEvent) => {
const list = heads();
const idx = list.indexOf(document.activeElement as HTMLButtonElement);
if (idx < 0) return; // 卫语句:焦点不在标题行
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); focusFrom(idx, e.key === 'ArrowDown' ? 1 : -1); }
else if (e.key === 'Home') { e.preventDefault(); focusFrom(-1, 1); } // 从头正向找第一个可用
else if (e.key === 'End') { e.preventDefault(); focusFrom(0, -1); } // 从尾反向找最后一个可用
});
return root;
}