iter(g29): 新增 carousel 轮播组件

This commit is contained in:
HIsheR
2026-10-26 11:40:07 +08:00
parent 84f83df615
commit e5ed297842
13 changed files with 312 additions and 9 deletions
+65
View File
@@ -0,0 +1,65 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { CarouselProps } from './types.js';
/*** 轮播:items 横排 + translateX 位移;指示点 / 左右箭头 / ←→ 键切换,autoplay 毫秒自动前进且悬停暂停 ***/
export function carousel(props: CarouselProps): HTMLElement {
// —— 状态集中声明 ——
const total = props.items.length;
const index = props.index ?? signal(0); // 未传外部信号时用内部信号,行为一致
const wrap = (i: number) => (total ? ((i % total) + total) % total : 0); // 环形取模,负数也回绕
const dots: HTMLButtonElement[] = []; // 指示点,顺序即下标
const track = h('div', { class: 'ua-carousel-track' }) as HTMLElement; // 幻灯片轨道,靠 transform 位移
const dotsBar = h('div', { class: 'ua-carousel-dots' }) as HTMLElement;
/*** 跳到指定下标(自动环形),并回传 onChange ***/
const go = (i: number) => {
if (!total) return; // 卫语句:无内容不动作
index(wrap(i));
props.onChange?.(index());
};
props.items.forEach((item, i) => {
track.append(h('div', { class: 'ua-carousel-slide', role: 'group', 'aria-label': `第 ${i + 1} 张` }, item));
const dot = h('button', { type: 'button', class: 'ua-carousel-dot', 'aria-label': `跳到第 ${i + 1} 张`, onClick: () => go(i) }) as HTMLButtonElement;
dots.push(dot);
dotsBar.append(dot);
});
/*** 生成左右箭头 ***/
const arrow = (dir: 1 | -1, label: string) =>
h('button', { type: 'button', class: `ua-carousel-arrow ua-carousel-${dir === 1 ? 'next' : 'prev'}`, 'aria-label': label, onClick: () => go(index() + dir) }, dir === 1 ? '›' : '‹');
const root = h(
'div',
{ class: 'ua-carousel', tabindex: '0', role: 'region', 'aria-label': props.label ?? '轮播' },
track,
arrow(-1, '上一张'),
arrow(1, '下一张'),
dotsBar,
) as HTMLElement;
if (props.height) root.style.height = `${props.height}px`; // 固定高度由调用方指定,缺省随内容
effect(() => {
const i = wrap(index());
track.style.transform = `translateX(${-i * 100}%)`; // 整屏位移,配合 CSS transition 出滑动效果
dots.forEach((dot, k) => {
dot.classList.toggle('ua-carousel-dot-active', k === i);
dot.setAttribute('aria-current', String(k === i)); // 读屏标注当前张
});
});
root.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; // 卫语句:只接管方向键
e.preventDefault();
e.stopPropagation(); // 不冒泡:避免外层 tabs/menu 同时响应
go(index() + (e.key === 'ArrowRight' ? 1 : -1));
});
if (props.autoplay && total > 1) {
let paused = false; // 悬停期间暂停自动播放
root.addEventListener('pointerenter', () => { paused = true; });
root.addEventListener('pointerleave', () => { paused = false; });
window.setInterval(() => { if (!paused) go(index() + 1); }, props.autoplay);
}
return root;
}
+1
View File
@@ -32,6 +32,7 @@ export { rate } from './rate.js';
export { empty } from './empty.js';
export { divider } from './divider.js';
export { list } from './list.js';
export { carousel } from './carousel.js';
export { descriptions } from './descriptions.js';
export { statistic } from './statistic.js';
export { timeline } from './timeline.js';
+10
View File
@@ -420,4 +420,14 @@ export interface ListProps {
onItemClick?: (item: ListItem, index: number) => void; // 条目点击回调;提供后条目可点击并带 hover 反馈
}
/*** 轮播入参 ***/
export interface CarouselProps {
items: unknown[]; // 每张幻灯片内容,顺序即播放顺序
index?: Signal<number>; // 当前下标信号;不传则内部维护
autoplay?: number; // 自动前进间隔(毫秒);缺省不自动播放
height?: number; // 固定高度像素;缺省由内容撑开
label?: string; // 区域无障碍名称;缺省「轮播」
onChange?: (index: number) => void; // 切换回调(点击/箭头/键盘/自动都触发)
}
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
+49 -1
View File
@@ -53,7 +53,8 @@
"segmented(props):分段控制器,胶囊分段 + 滑动滑块,value 信号双向绑定,←→ 环形导航,整组或单项可禁用",
"descriptions(props):描述列表,items(label/value/span) 铺进 column 列网格,bordered 切表格形态,窄屏自动单列",
"statistic(props):统计数值,标题 + 大号数字 + 前后缀 + 环比趋势,value 变化时 rAF 滚动过渡(400ms easeOutCubic),减弱动效偏好下直落终值",
"list(props):列表,header/footer 外壳 + 条目 avatar/title/description/extra,onItemClick 让条目可点击,items 为空回落空状态"
"list(props):列表,header/footer 外壳 + 条目 avatar/title/description/extra,onItemClick 让条目可点击,items 为空回落空状态",
"carousel(props):轮播,items 横排位移,指示点/箭头/←→ 键切换,autoplay 毫秒自动前进且悬停暂停,index 信号可外部受控"
],
"keyTypes": [
"ButtonProps",
@@ -1684,6 +1685,53 @@
"需要点击时传 onItemClick(item, index),条目自动加 hover 底色;bordered: true 给外边框与分隔线",
"items 为空数组时自动渲染 empty 占位,文案可用 emptyText 覆盖"
]
},
{
"id": "carousel",
"title": "轮播 Carousel",
"demo": "sec-carousel",
"props": [
{
"n": "items",
"t": "unknown[]",
"r": 1,
"d": "每张幻灯片内容,顺序即播放顺序"
},
{
"n": "index",
"t": "Signal<number>",
"d": "当前下标信号;不传则内部维护"
},
{
"n": "autoplay",
"t": "number",
"d": "自动前进间隔(毫秒);缺省不自动播放"
},
{
"n": "height",
"t": "number",
"d": "固定高度像素;缺省由内容撑开"
},
{
"n": "label",
"t": "string",
"d": "区域无障碍名称;缺省「轮播」"
},
{
"n": "onChange",
"t": "(index: number) => void",
"k": 1,
"d": "切换回调:点击指示点/箭头/键盘/自动前进都会触发"
}
],
"example": "const i = signal(0); carousel({ items: [h('div', null, 'A'), h('div', null, 'B')], index: i, height: 150, autoplay: 4000, onChange: (k) => console.log(k) })",
"steps": [
"items 每项是任意节点(图、卡片、文字均可),顺序即播放顺序",
"index 传自己的信号即可外部受控(外部改信号轮播会跟),不传则由组件内部维护",
"height 控固定高度(缺省随内容撑开);autoplay 传毫秒数自动前进,鼠标悬停自动暂停",
"键盘:聚焦轮播后 ←→ 切换,事件不冒泡避免连带切换外层 tabs/menu",
"指示点与箭头都可点击,末张下一张环形回到首张"
]
}
]
}
+71
View File
@@ -2071,3 +2071,74 @@ button.ua-step-dot:hover {
transition: none;
}
}
/* —— 轮播 —— */
.ua-carousel {
position: relative;
overflow: hidden;
border-radius: var(--ua-radius-md);
background: var(--ua-color-surface);
outline: none;
}
.ua-carousel:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 45%, transparent);
}
.ua-carousel-track {
display: flex;
height: 100%;
transition: transform 0.32s ease;
}
.ua-carousel-slide {
flex: 0 0 100%;
display: flex;
}
.ua-carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
border: 1px solid var(--ua-color-border);
border-radius: 50%;
background: var(--ua-color-bg);
color: var(--ua-color-text);
font-size: var(--ua-font-size-lg);
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.ua-carousel-prev { left: var(--ua-space-2); }
.ua-carousel-next { right: var(--ua-space-2); }
.ua-carousel:hover .ua-carousel-arrow,
.ua-carousel:focus-within .ua-carousel-arrow { opacity: 1; }
.ua-carousel-arrow:hover { background: var(--ua-color-surface); }
.ua-carousel-dots {
position: absolute;
left: 0;
right: 0;
bottom: var(--ua-space-2);
display: flex;
gap: var(--ua-space-1);
justify-content: center;
}
.ua-carousel-dot {
width: 6px;
height: 6px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--ua-color-border);
cursor: pointer;
transition: width 0.2s ease, background 0.2s ease;
}
.ua-carousel-dot-active {
width: 16px;
border-radius: var(--ua-radius-sm);
background: var(--ua-color-accent);
}
@media (prefers-reduced-motion: reduce) {
.ua-carousel-track,
.ua-carousel-dot,
.ua-carousel-arrow { transition: none; }
}