diff --git a/.ua/backlog.json b/.ua/backlog.json index 64ec07d..eed9ce7 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -1276,6 +1276,14 @@ "acceptance": "npm run changelog 重新生成(301 提交),changelog:check 与提交史一致;npm run check 全绿", "notes": "自补:发版前刷新步骤;本条为本次唤起收尾轮", "status": "done" + }, + { + "id": "g115", + "title": "新增月历组件 calendar", + "motivation": "排期表、值班表、日程页需要「整月网格一直看得见」,datePicker 只能弹层选一天且收起来就没了;调用方要自己算月初留白、当月天数、今天与选中的标记,还要补键盘漫游,一写就是一百多行重复逻辑", + "acceptance": "新增 calendar(props)(< 200 行):value/month 信号、marker 每日标记(文案同时进 aria-label)、onSelect / onMonthChange;月初留白与天数计算抽到 dateKit.ts,datePicker 改为共用同一份(消除两处日期工具);组件 59→60,types/index/css/manifest/docs/README/AGENTS/ARCHITECTURE/demo 同步;npm run check 全绿", + "notes": "自补:组件补充 + 派生语义单一来源(日期工具下沉 dateKit)", + "status": "open" } ] } diff --git a/.ua/index.json b/.ua/index.json index e747be1..f365150 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T06:06:20.403Z", + "generatedAt": "2026-10-26T06:08:45.187Z", "repoRoot": ".", "packages": [ { @@ -202,7 +202,8 @@ "affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应", "multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用", "anchor(props):锚点导航,竖排区块链接,滚动时按目标位置高亮当前区块(aria-current 同步),点击按 offset 偏移量平滑定位并回写 hash;container 可指定内滚容器", - "popconfirm(props, trigger):二次确认气泡,挂在任意触发器上,就地确认危险操作;确定后先收起再回调,取消 / 点外部 / Esc 走 onCancel,展开即聚焦「取消」" + "popconfirm(props, trigger):二次确认气泡,挂在任意触发器上,就地确认危险操作;确定后先收起再回调,取消 / 点外部 / Esc 走 onCancel,展开即聚焦「取消」", + "calendar(props):月历,整月网格内嵌页面(排期 / 值班 / 日程),每日可带 marker 标记文案,方向键漫游 + Enter 选中,翻月走 onMonthChange" ], "keyTypes": [ "ButtonProps", @@ -2989,6 +2990,48 @@ "点确定先收起弹层再触发 onConfirm,回调里可以放心同步改数据;取消、点外部、Esc 一律走 onCancel", "展开时焦点自动落在「取消」上,误按回车不会执行危险操作;需要外部控制开合时传 open 信号" ] + }, + { + "id": "calendar", + "title": "月历 Calendar", + "demo": "sec-calendar", + "props": [ + { + "n": "value", + "t": "Signal", + "d": "选中日期(YYYY-MM-DD)信号;不传则内部维护" + }, + { + "n": "month", + "t": "Signal", + "d": "展示月份(YYYY-MM);不传则内部维护,初始跟随 value 或今天" + }, + { + "n": "marker", + "t": "(date: string) => string | null", + "k": 1, + "d": "每日标记:返回文案即渲染小徽标(如「3 项」),同时写进 aria-label;返回 null 不标记" + }, + { + "n": "onSelect", + "t": "(date: string) => void", + "k": 1, + "d": "选中日期回调(点击或 Enter / 空格)" + }, + { + "n": "onMonthChange", + "t": "(month: string) => void", + "k": 1, + "d": "翻月回调,参数为 YYYY-MM(可在此拉取当月数据)" + } + ], + "example": "const day = signal(''); calendar({ value: day, marker: (d) => (d === '2026-10-26' ? '2 项' : null), onSelect: (d) => toast.info(d) })", + "steps": [ + "把 calendar 放在页面容器里即可,它自带标题行(月份 + 上下月箭头)、星期表头与 6 行网格", + "选中日期用 value 信号双向绑定;不传则组件内部维护,onSelect 里拿字符串自己处理", + "需要显示每天有几件事就传 marker:返回非空文案即渲染小徽标,并拼进 aria-label 让读屏也听得到", + "键盘:Tab 进入网格一次,←→ 逐日、↑↓ 逐周、Enter / 空格 选中;跨月会自动翻页并交接焦点" + ] } ] }, diff --git a/AGENTS.md b/AGENTS.md index 98f7a0a..2f54447 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ - `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核 - `packages/core/frame`(@ui-arch/frame):signal + hyperscript 微框架 -- `packages/skin/ui`(@ui-arch/ui):59 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 +- `packages/skin/ui`(@ui-arch/ui):60 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 - `packages/design/tokens`(@ui-arch/tokens):设计令牌 - `packages/tools/cli`(@ui-arch/cli):`ua` 命令行 + MCP 注册表服务(private) - `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5109e07..26601bb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,7 +34,7 @@ ui-arch-mvp/ │ │ ├── table-core/ # 表格状态内核 │ │ └── frame/ # 信号 + hyperscript 微框架 │ ├── skin/ # 皮肤层(DOM 组件,可发布) -│ │ └── ui/ # 59 个组件 + ui.css +│ │ └── ui/ # 60 个组件 + ui.css │ ├── design/ # 资产层(可发布) │ │ └── tokens/ # --ua-* 设计令牌 │ └── tools/ # 工程层(private,不发布) diff --git a/README.md b/README.md index 2cc08b4..f732778 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全 npm run changelog # 依据 git 提交史重写 CHANGELOG.md(发版前跑;changelog:check 可查漂移) ``` -## 组件(共 59 个) +## 组件(共 60 个) -accordion · affix · alert · anchor · autoComplete · avatar · backTop · badge · breadcrumb · button · carousel · card · cascader · checkbox · checkboxGroup · colorPicker · dataTable · datePicker · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · multiSelect · notification · pagination · popover · popconfirm · progress · radioGroup · rate · result · segmented · select · skeleton · slider · space · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tour · transfer · tree · upload · watermark +accordion · affix · alert · anchor · autoComplete · avatar · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · colorPicker · dataTable · datePicker · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · multiSelect · notification · pagination · popover · popconfirm · progress · radioGroup · rate · result · segmented · select · skeleton · slider · space · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tour · transfer · tree · upload · watermark -分组:表单(button / textField / inputNumber / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / popconfirm / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / anchor)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel)、其他(space / divider / splitter / backTop / affix / tour / watermark)。 +分组:表单(button / textField / inputNumber / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / popconfirm / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / anchor)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel / calendar)、其他(space / divider / splitter / backTop / affix / tour / watermark)。 ## 可访问性约定 @@ -32,7 +32,7 @@ accordion · affix · alert · anchor · autoComplete · avatar · backTop · ba |---|---| | `packages/core/frame` | @ui-arch/frame:signal / computed / effect + hyperscript(无虚拟 DOM) | | `packages/core/table-core` | @ui-arch/table-core:headless 表格(filter → sort → paginate 派生管线) | -| `packages/skin/ui` | @ui-arch/ui:59 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | +| `packages/skin/ui` | @ui-arch/ui:60 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | | `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) | | `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server | | `apps/demo` | 交互演示(esbuild 打包成单文件) | diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 3fb46f6..7588787 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -18,6 +18,7 @@ import { badge, breadcrumb, button, + calendar, card, cascader, checkbox, @@ -132,6 +133,15 @@ const users = [ 'Quinn', 'Rose', 'Sam', 'Tina', 'Uma', 'Victor', 'Wendy', 'Xavier', ].map((name, i) => ({ id: i + 1, name, age: 22 + ((i * 7) % 20), city: cities[i % cities.length] })); +// —— 月历标记:以今天为基准前后几天,避免把日期写死 —— +const dayKey = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() + n); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; +const calendarMarks: Record = { [dayKey(0)]: '2 项', [dayKey(2)]: '1 项', [dayKey(-3)]: '5 项' }; +const calDay = signal(dayKey(0)); + // —— 响应式状态演示 —— const count = signal(0); @@ -1218,6 +1228,17 @@ const content = h( }, button({ label: '删除记录', variant: 'danger' })), popconfirm({ title: '发布到生产?', description: '发布后 1 分钟内全量生效。', confirmText: '发布', onConfirm: () => toast.success('已发布') }, button({ label: '发布', variant: 'ghost' })), ), + section( + 'sec-calendar', + '月历', + 'calendar 把整月网格内嵌进页面(排期 / 值班 / 日程):每日可带标记文案,标记同时写进 aria-label 让读屏也听得到;←→ 逐日、↑↓ 逐周、Enter 选中,跨月自动翻页并交接焦点。', + calendar({ + value: calDay, + marker: (date) => calendarMarks[date] ?? null, + onSelect: (date) => toast.info(`选中 ${date}`), + }), + h('span', { class: 'out' }, () => `当前选中:${calDay() || '无'}`), + ), ); // 引导漫游实例:根节点需挂到 body(fixed 定位,不参与布局) diff --git a/llms-full.txt b/llms-full.txt index 1900a19..f3b8efa 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T06:06:20.403Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T06:08:45.187Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -1629,6 +1629,31 @@ popconfirm({ title: '删除这条记录?', description: '删除后无法恢复 现场演示:apps/docs/demo.html#sec-popconfirm +### calendar — 月历 Calendar + +参数: + +- value:Signal —— 选中日期(YYYY-MM-DD)信号;不传则内部维护 +- month:Signal —— 展示月份(YYYY-MM);不传则内部维护,初始跟随 value 或今天 +- marker(回调):(date: string) => string | null —— 每日标记:返回文案即渲染小徽标(如「3 项」),同时写进 aria-label;返回 null 不标记 +- onSelect(回调):(date: string) => void —— 选中日期回调(点击或 Enter / 空格) +- onMonthChange(回调):(month: string) => void —— 翻月回调,参数为 YYYY-MM(可在此拉取当月数据) + +示例: + +``` +const day = signal(''); calendar({ value: day, marker: (d) => (d === '2026-10-26' ? '2 项' : null), onSelect: (d) => toast.info(d) }) +``` + +手把手: + +1. 把 calendar 放在页面容器里即可,它自带标题行(月份 + 上下月箭头)、星期表头与 6 行网格 +2. 选中日期用 value 信号双向绑定;不传则组件内部维护,onSelect 里拿字符串自己处理 +3. 需要显示每天有几件事就传 marker:返回非空文案即渲染小徽标,并拼进 aria-label 让读屏也听得到 +4. 键盘:Tab 进入网格一次,←→ 逐日、↑↓ 逐周、Enter / 空格 选中;跨月会自动翻页并交接焦点 + +现场演示:apps/docs/demo.html#sec-calendar + API 速览: - button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用;type 缺省 button(表单内不隐式提交) @@ -1692,6 +1717,7 @@ API 速览: - multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用 - anchor(props):锚点导航,竖排区块链接,滚动时按目标位置高亮当前区块(aria-current 同步),点击按 offset 偏移量平滑定位并回写 hash;container 可指定内滚容器 - popconfirm(props, trigger):二次确认气泡,挂在任意触发器上,就地确认危险操作;确定后先收起再回调,取消 / 点外部 / Esc 走 onCancel,展开即聚焦「取消」 +- calendar(props):月历,整月网格内嵌页面(排期 / 值班 / 日程),每日可带 marker 标记文案,方向键漫游 + Enter 选中,翻月走 onMonthChange 一行用法: diff --git a/llms.txt b/llms.txt index 174bc32..f515d3f 100644 --- a/llms.txt +++ b/llms.txt @@ -108,6 +108,7 @@ - multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用 - anchor(props):锚点导航,竖排区块链接,滚动时按目标位置高亮当前区块(aria-current 同步),点击按 offset 偏移量平滑定位并回写 hash;container 可指定内滚容器 - popconfirm(props, trigger):二次确认气泡,挂在任意触发器上,就地确认危险操作;确定后先收起再回调,取消 / 点外部 / Esc 走 onCancel,展开即聚焦「取消」 + - calendar(props):月历,整月网格内嵌页面(排期 / 值班 / 日程),每日可带 marker 标记文案,方向键漫游 + Enter 选中,翻月走 onMonthChange - 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')` - 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号 - 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调 diff --git a/packages/skin/ui/src/calendar.ts b/packages/skin/ui/src/calendar.ts new file mode 100644 index 0000000..3721d29 --- /dev/null +++ b/packages/skin/ui/src/calendar.ts @@ -0,0 +1,89 @@ +import { effect, h, signal } from '@ui-arch/frame'; +import type { CalendarProps } from './types.js'; +import { fmt, monthGrid, pad, parse, shiftDay, today } from './dateKit.js'; + +/*** 月历:整月网格直接内嵌在页面里(排期表 / 值班表 / 日程选择),每日可带标记文案,方向键漫游 + Enter 选中 ***/ +export function calendar(props: CalendarProps): HTMLElement { + // —— 状态集中声明 —— + const value = props.value ?? signal(''); // 选中日期(YYYY-MM-DD) + const from = parse(value()) ?? today(); // 初始定位:跟随选中值,否则今天 + const month = props.month ?? signal(`${from[0]}-${pad(from[1] + 1)}`); // 展示月份(YYYY-MM) + const focus = signal(value() || fmt(...today())); // 键盘漫游焦点日 + const grid = h('div', { class: 'ua-calendar-grid', role: 'grid' }); + const label = h('span', { class: 'ua-calendar-label' }); + + /*** 当前展示的年月:月份串非法时回落到初始定位 ***/ + const view = (): [number, number] => { + const hit = /^(\d{4})-(\d{2})$/.exec(month()); + return hit ? [Number(hit[1]), Number(hit[2]) - 1] : [from[0], from[1]]; + }; + + /*** 翻月:只改展示,不动选中值;跨年自动进位 ***/ + const turn = (delta: number): void => { + const [y, m] = view(); + const d = new Date(y, m + delta, 1); + const next = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; + month(next); + props.onMonthChange?.(next); + }; + + /*** 选择:写回信号 + 回调,焦点同步到该日 ***/ + const pick = (date: string): void => { + value(date); + focus(date); + props.onSelect?.(date); + }; + + /*** 重绘网格:月初留白、今天与选中分别打标,漫游 tabindex 保证 Tab 只进一次 ***/ + const render = (): void => { + const [y, m] = view(); + const { lead, total } = monthGrid(y, m); + const cells: HTMLElement[] = Array.from({ length: lead }, () => h('span', { class: 'ua-calendar-blank' })); + const nowKey = fmt(...today()); + for (let d = 1; d <= total; d++) { + const date = fmt(y, m, d); + const mark = props.marker?.(date) ?? null; + const cls = `ua-calendar-day${date === value() ? ' is-selected' : ''}${date === nowKey ? ' is-today' : ''}`; + cells.push(h('button', { + type: 'button', + class: cls, + role: 'gridcell', + 'aria-selected': String(date === value()), + tabindex: date === focus() ? '0' : '-1', + 'aria-label': mark ? `${date},${mark}` : date, // 标记文案同时进无障碍名,读屏也能听到「几项」 + onClick: () => pick(date), + }, h('span', { class: 'ua-calendar-num' }, String(d)), mark ? h('span', { class: 'ua-calendar-mark' }, mark) : null)); + } + grid.replaceChildren(...cells); + label.textContent = `${y} 年 ${m + 1} 月`; + }; + + // —— 键盘:←→ 逐日、↑↓ 逐周、Enter / 空格 选中;跨月自动翻页后把焦点交给新网格 —— + grid.addEventListener('keydown', (e: KeyboardEvent) => { + const step: Record = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }; + if (!step[e.key]) { + if (e.key !== 'Enter' && e.key !== ' ') return; // 卫语句:只接管选中键 + e.preventDefault(); + pick(focus()); + return; + } + e.preventDefault(); + const next = shiftDay(focus(), step[e.key]); + const p = parse(next) ?? today(); + focus(next); + const [y, m] = view(); + if (p[0] !== y || p[1] !== m) month(`${p[0]}-${pad(p[1] + 1)}`); // 跨月:先翻页,重绘交给 effect + requestAnimationFrame(() => (grid.querySelector('.ua-calendar-day[tabindex="0"]') as HTMLElement | null)?.focus()); + }); + + const head = h( + 'div', + { class: 'ua-calendar-head' }, + h('button', { type: 'button', class: 'ua-calendar-nav', 'aria-label': '上一月', onClick: () => turn(-1) }, '‹'), + label, + h('button', { type: 'button', class: 'ua-calendar-nav', 'aria-label': '下一月', onClick: () => turn(1) }, '›'), + ); + const week = h('div', { class: 'ua-calendar-week' }, ...['日', '一', '二', '三', '四', '五', '六'].map((w) => h('span', {}, w))); + effect(render); // month / value / focus 任一变化即重绘 + return h('div', { class: 'ua-calendar' }, head, week, grid); +} diff --git a/packages/skin/ui/src/dateKit.ts b/packages/skin/ui/src/dateKit.ts new file mode 100644 index 0000000..e3a9d0c --- /dev/null +++ b/packages/skin/ui/src/dateKit.ts @@ -0,0 +1,30 @@ +/*** 日期工具:datePicker 与 calendar 共用一套格式化与网格语义,避免两处各写一遍 ***/ +export const pad = (n: number): string => String(n).padStart(2, '0'); // 两位补零 + +/*** 拼成 YYYY-MM-DD ***/ +export const fmt = (y: number, m: number, d: number): string => `${y}-${pad(m + 1)}-${pad(d)}`; + +/*** 解析 YYYY-MM-DD:非法串返回 null,调用方自行回落 ***/ +export const parse = (s: string): [number, number, number] | null => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); + return m ? [Number(m[1]), Number(m[2]) - 1, Number(m[3])] : null; +}; + +/*** 今天 ***/ +export const today = (): [number, number, number] => { + const d = new Date(); + return [d.getFullYear(), d.getMonth(), d.getDate()]; +}; + +/*** 按天偏移:跨月 / 跨年自动进位,非法输入回落到今天 ***/ +export const shiftDay = (key: string, delta: number): string => { + const p = parse(key) ?? today(); + const d = new Date(p[0], p[1], p[2] + delta); + return fmt(d.getFullYear(), d.getMonth(), d.getDate()); +}; + +/*** 单月网格元数据:月初留白格数与当月天数 ***/ +export const monthGrid = (y: number, m: number): { lead: number; total: number } => ({ + lead: new Date(y, m, 1).getDay(), // 1 号是周几 → 前面留几个空位 + total: new Date(y, m + 1, 0).getDate(), // 下月第 0 天即本月最后一天 +}); diff --git a/packages/skin/ui/src/datePicker.ts b/packages/skin/ui/src/datePicker.ts index 7ccc190..d319bd3 100644 --- a/packages/skin/ui/src/datePicker.ts +++ b/packages/skin/ui/src/datePicker.ts @@ -3,22 +3,7 @@ import type { Signal } from '@ui-arch/frame'; import { bindOutside, pushOverlay } from './overlay.js'; import { flip } from './position.js'; import type { DatePickerProps } from './types.js'; - -const pad = (n: number): string => String(n).padStart(2, '0'); // 两位补零 -const fmt = (y: number, m: number, d: number): string => `${y}-${pad(m + 1)}-${pad(d)}`; // 拼成 YYYY-MM-DD -const parse = (s: string): [number, number, number] | null => { - const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); - return m ? [Number(m[1]), Number(m[2]) - 1, Number(m[3])] : null; // 非法串返回 null,调用方回落到今天 -}; -const today = (): [number, number, number] => { - const d = new Date(); - return [d.getFullYear(), d.getMonth(), d.getDate()]; -}; -const shiftDay = (key: string, delta: number): string => { - const p = parse(key) ?? today(); - const d = new Date(p[0], p[1], p[2] + delta); - return fmt(d.getFullYear(), d.getMonth(), d.getDate()); -}; +import { fmt, pad, parse, shiftDay, today } from './dateKit.js'; /*** 日期选择:单月网格 + 上下月翻页 + 今天快捷键,value 信号双向绑定,方向键/PageUp/PageDown 可导航 ***/ export function datePicker(props: DatePickerProps): HTMLElement { diff --git a/packages/skin/ui/src/index.ts b/packages/skin/ui/src/index.ts index c544a91..123833f 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -59,3 +59,4 @@ export { watermark } from './watermark.js'; export { affix } from './affix.js'; export { anchor } from './anchor.js'; export { popconfirm } from './popconfirm.js'; +export { calendar } from './calendar.js'; diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 596cda8..8bb9ee5 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -725,4 +725,13 @@ export interface PopconfirmProps { open?: Signal; // 受控开合信号;不传则内部维护 } +/*** 月历入参 ***/ +export interface CalendarProps { + value?: Signal; // 选中日期(YYYY-MM-DD)信号;不传则内部维护 + month?: Signal; // 展示月份(YYYY-MM);不传则内部维护,初始跟随 value 或今天 + marker?: (date: string) => string | null; // 每日标记:返回文案即渲染小徽标(如「3 项」),返回 null 不标记 + onSelect?: (date: string) => void; // 选中日期回调(点击或 Enter / 空格) + onMonthChange?: (month: string) => void; // 翻月回调,参数为 YYYY-MM +} + export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方 diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index f4eb96c..bb3b90f 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -73,7 +73,8 @@ "affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应", "multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用", "anchor(props):锚点导航,竖排区块链接,滚动时按目标位置高亮当前区块(aria-current 同步),点击按 offset 偏移量平滑定位并回写 hash;container 可指定内滚容器", - "popconfirm(props, trigger):二次确认气泡,挂在任意触发器上,就地确认危险操作;确定后先收起再回调,取消 / 点外部 / Esc 走 onCancel,展开即聚焦「取消」" + "popconfirm(props, trigger):二次确认气泡,挂在任意触发器上,就地确认危险操作;确定后先收起再回调,取消 / 点外部 / Esc 走 onCancel,展开即聚焦「取消」", + "calendar(props):月历,整月网格内嵌页面(排期 / 值班 / 日程),每日可带 marker 标记文案,方向键漫游 + Enter 选中,翻月走 onMonthChange" ], "keyTypes": [ "ButtonProps", @@ -2860,6 +2861,48 @@ "点确定先收起弹层再触发 onConfirm,回调里可以放心同步改数据;取消、点外部、Esc 一律走 onCancel", "展开时焦点自动落在「取消」上,误按回车不会执行危险操作;需要外部控制开合时传 open 信号" ] + }, + { + "id": "calendar", + "title": "月历 Calendar", + "demo": "sec-calendar", + "props": [ + { + "n": "value", + "t": "Signal", + "d": "选中日期(YYYY-MM-DD)信号;不传则内部维护" + }, + { + "n": "month", + "t": "Signal", + "d": "展示月份(YYYY-MM);不传则内部维护,初始跟随 value 或今天" + }, + { + "n": "marker", + "t": "(date: string) => string | null", + "k": 1, + "d": "每日标记:返回文案即渲染小徽标(如「3 项」),同时写进 aria-label;返回 null 不标记" + }, + { + "n": "onSelect", + "t": "(date: string) => void", + "k": 1, + "d": "选中日期回调(点击或 Enter / 空格)" + }, + { + "n": "onMonthChange", + "t": "(month: string) => void", + "k": 1, + "d": "翻月回调,参数为 YYYY-MM(可在此拉取当月数据)" + } + ], + "example": "const day = signal(''); calendar({ value: day, marker: (d) => (d === '2026-10-26' ? '2 项' : null), onSelect: (d) => toast.info(d) })", + "steps": [ + "把 calendar 放在页面容器里即可,它自带标题行(月份 + 上下月箭头)、星期表头与 6 行网格", + "选中日期用 value 信号双向绑定;不传则组件内部维护,onSelect 里拿字符串自己处理", + "需要显示每天有几件事就传 marker:返回非空文案即渲染小徽标,并拼进 aria-label 让读屏也听得到", + "键盘:Tab 进入网格一次,←→ 逐日、↑↓ 逐周、Enter / 空格 选中;跨月会自动翻页并交接焦点" + ] } ] } diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index e411416..45b0f2a 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -3826,3 +3826,99 @@ button.ua-step-dot:hover { outline: 2px solid var(--ua-color-accent); outline-offset: 2px; } + +/* —— 月历 —— */ +.ua-calendar { + display: inline-block; + min-width: 252px; + padding: var(--ua-space-3); + border: 1px solid var(--ua-color-border); + border-radius: var(--ua-radius-md); + background: var(--ua-color-bg); +} +.ua-calendar-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--ua-space-2); +} +.ua-calendar-label { + color: var(--ua-color-text); + font-size: var(--ua-font-size-md); + font-weight: 500; +} +.ua-calendar-nav { + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: var(--ua-radius-sm); + background: none; + color: var(--ua-color-text-muted); + cursor: pointer; + transition: color 0.15s, background-color 0.15s; +} +.ua-calendar-nav:hover { + color: var(--ua-color-accent); + background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent); +} +.ua-calendar-nav:focus-visible { + outline: 2px solid var(--ua-color-accent); + outline-offset: 1px; +} +.ua-calendar-week, +.ua-calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} +.ua-calendar-week { + margin-bottom: var(--ua-space-1); + color: var(--ua-color-text-muted); + font-size: var(--ua-font-size-sm); + text-align: center; +} +.ua-calendar-day { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + padding: 4px 0; + border: none; + border-radius: var(--ua-radius-sm); + background: none; + color: var(--ua-color-text); + font-size: var(--ua-font-size-md); + cursor: pointer; + transition: background-color 0.15s, color 0.15s, transform 0.15s; +} +.ua-calendar-day:hover { + background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent); + transform: translateY(-1px); +} +.ua-calendar-day.is-today { + box-shadow: inset 0 0 0 1px var(--ua-color-border); /* 今天只用细内框,不与选中态抢视觉 */ +} +.ua-calendar-day.is-selected { + background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent); + color: var(--ua-color-accent); + font-weight: 500; + transform: none; +} +.ua-calendar-day:focus-visible { + outline: 2px solid var(--ua-color-accent); + outline-offset: 1px; +} +.ua-calendar-mark { + color: var(--ua-color-text-muted); + font-size: 10px; + line-height: 1; +} +@media (prefers-reduced-motion: reduce) { + .ua-calendar-day { + transition: none; + } + .ua-calendar-day:hover { + transform: none; + } +}