diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index dcad734..a221ee1 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,150 +1,163 @@ -import react from '@vitejs/plugin-react' -import { defineConfig } from 'vitepress' +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitepress"; export default defineConfig({ - base: '/react-ui-library/', - title: 'Nova UI', - description: 'Enterprise React component library with TypeScript and Tailwind CSS', - lang: 'zh-CN', + base: "/react-ui-library/", + title: "Nova UI", + description: + "Enterprise React component library with TypeScript and Tailwind CSS", + lang: "zh-CN", appearance: true, themeConfig: { - darkModeSwitchLabel: '主题', - lightModeSwitchTitle: '切换到浅色模式', - darkModeSwitchTitle: '切换到深色模式', - logo: '/favicon.svg', + darkModeSwitchLabel: "主题", + lightModeSwitchTitle: "切换到浅色模式", + darkModeSwitchTitle: "切换到深色模式", + logo: "/favicon.svg", nav: [ - { text: '指南', link: '/guide/introduction' }, - { text: '组件', link: '/components/overview' }, - { text: '在线示例', link: '/playground' }, + { text: "指南", link: "/guide/introduction" }, + { text: "组件", link: "/components/overview" }, + { text: "在线示例", link: "/playground" }, ], sidebar: { - '/guide/': [ + "/guide/": [ { - text: '开始', + text: "开始", items: [ - { text: '项目介绍', link: '/guide/introduction' }, - { text: '快速开始', link: '/guide/getting-started' }, - { text: '文档优化 TODO', link: '/guide/docs-optimization-todo' }, + { text: "项目介绍", link: "/guide/introduction" }, + { text: "快速开始", link: "/guide/getting-started" }, + { text: "可访问性", link: "/guide/accessibility" }, + { text: "文档优化 TODO", link: "/guide/docs-optimization-todo" }, ], }, ], - '/components/': [ - { text: '概览', link: '/components/overview' }, + "/components/": [ + { text: "概览", link: "/components/overview" }, { - text: '布局', + text: "布局", collapsed: false, items: [ - { text: 'Layout 页面骨架', link: '/components/layout' }, - { text: 'Container 布局容器', link: '/components/container' }, - { text: 'Row 行', link: '/components/row' }, - { text: 'Col 列', link: '/components/col' }, - { text: 'Grid 网格', link: '/components/grid' }, - { text: 'Flex 弹性布局', link: '/components/flex' }, - { text: 'Space 间距', link: '/components/space' }, - { text: 'Divider 分割线', link: '/components/divider' }, - { text: 'SplitPane 分栏', link: '/components/split-pane' }, + { text: "Layout 页面骨架", link: "/components/layout" }, + { text: "Container 布局容器", link: "/components/container" }, + { text: "Row 行", link: "/components/row" }, + { text: "Col 列", link: "/components/col" }, + { text: "Grid 网格", link: "/components/grid" }, + { text: "Flex 弹性布局", link: "/components/flex" }, + { text: "Space 间距", link: "/components/space" }, + { text: "Divider 分割线", link: "/components/divider" }, + { text: "SplitPane 分栏", link: "/components/split-pane" }, ], }, { - text: '基础', + text: "基础", collapsed: false, items: [ - { text: 'Button 按钮', link: '/components/button' }, - { text: 'Icon 图标', link: '/components/icon' }, - { text: 'Typography 排版', link: '/components/typography' }, + { text: "Button 按钮", link: "/components/button" }, + { text: "Icon 图标", link: "/components/icon" }, + { text: "Typography 排版", link: "/components/typography" }, ], }, { - text: '表单', + text: "表单", collapsed: false, items: [ - { text: 'Input 输入框', link: '/components/input' }, - { text: 'InputNumber 数字输入框', link: '/components/input-number' }, - { text: 'AutoComplete 自动完成', link: '/components/auto-complete' }, - { text: 'Select 选择器', link: '/components/select' }, - { text: 'Checkbox 多选框', link: '/components/checkbox' }, - { text: 'Radio 单选框', link: '/components/radio' }, - { text: 'Switch 开关', link: '/components/switch' }, - { text: 'DatePicker 日期选择', link: '/components/date-picker' }, - { text: 'TimePicker 时间选择', link: '/components/time-picker' }, - { text: 'Slider 滑动条', link: '/components/slider' }, - { text: 'Rate 评分', link: '/components/rate' }, - { text: 'Upload 上传', link: '/components/upload' }, - { text: 'Form 表单', link: '/components/form' }, - { text: 'Calendar 日历', link: '/components/calendar' }, - { text: 'Transfer 穿梭框', link: '/components/transfer' }, - { text: 'Cascader 级联选择', link: '/components/cascader' }, - { text: 'TreeSelect 树选择', link: '/components/tree-select' }, - { text: 'ColorPicker 颜色选择', link: '/components/color-picker' }, - { text: 'Segmented 分段控制器', link: '/components/segmented' }, - { text: 'Mentions 提及', link: '/components/mentions' }, + { text: "Input 输入框", link: "/components/input" }, + { + text: "InputNumber 数字输入框", + link: "/components/input-number", + }, + { + text: "AutoComplete 自动完成", + link: "/components/auto-complete", + }, + { text: "Select 选择器", link: "/components/select" }, + { text: "Checkbox 多选框", link: "/components/checkbox" }, + { text: "Radio 单选框", link: "/components/radio" }, + { text: "Switch 开关", link: "/components/switch" }, + { text: "DatePicker 日期选择", link: "/components/date-picker" }, + { text: "TimePicker 时间选择", link: "/components/time-picker" }, + { text: "Slider 滑动条", link: "/components/slider" }, + { text: "Rate 评分", link: "/components/rate" }, + { text: "Upload 上传", link: "/components/upload" }, + { text: "Form 表单", link: "/components/form" }, + { text: "Calendar 日历", link: "/components/calendar" }, + { text: "Transfer 穿梭框", link: "/components/transfer" }, + { text: "Cascader 级联选择", link: "/components/cascader" }, + { text: "TreeSelect 树选择", link: "/components/tree-select" }, + { text: "ColorPicker 颜色选择", link: "/components/color-picker" }, + { text: "Segmented 分段控制器", link: "/components/segmented" }, + { text: "Mentions 提及", link: "/components/mentions" }, ], }, { - text: '反馈', + text: "反馈", collapsed: false, items: [ - { text: 'Alert 警告提示', link: '/components/alert' }, - { text: 'Modal 对话框', link: '/components/modal' }, - { text: 'Drawer 抽屉', link: '/components/drawer' }, - { text: 'Toast 轻提示', link: '/components/toast' }, - { text: 'Tooltip 文字提示', link: '/components/tooltip' }, - { text: 'Popover 气泡卡片', link: '/components/popover' }, - { text: 'Popconfirm 气泡确认', link: '/components/popconfirm' }, - { text: 'Loading 加载中', link: '/components/loading' }, - { text: 'Spin 加载动画', link: '/components/spin' }, - { text: 'Skeleton 骨架屏', link: '/components/skeleton' }, - { text: 'Notification 通知', link: '/components/notification' }, - { text: 'Tour 漫游式引导', link: '/components/tour' }, - { text: 'Watermark 水印', link: '/components/watermark' }, + { text: "Alert 警告提示", link: "/components/alert" }, + { text: "Modal 对话框", link: "/components/modal" }, + { text: "Drawer 抽屉", link: "/components/drawer" }, + { text: "Toast 轻提示", link: "/components/toast" }, + { text: "Tooltip 文字提示", link: "/components/tooltip" }, + { text: "Popover 气泡卡片", link: "/components/popover" }, + { text: "Popconfirm 气泡确认", link: "/components/popconfirm" }, + { text: "Loading 加载中", link: "/components/loading" }, + { text: "Spin 加载动画", link: "/components/spin" }, + { text: "Skeleton 骨架屏", link: "/components/skeleton" }, + { text: "Notification 通知", link: "/components/notification" }, + { text: "Tour 漫游式引导", link: "/components/tour" }, + { text: "Watermark 水印", link: "/components/watermark" }, ], }, { - text: '数据展示', + text: "数据展示", collapsed: false, items: [ - { text: 'Table 表格', link: '/components/table' }, - { text: 'List 列表', link: '/components/list' }, - { text: 'Card 卡片', link: '/components/card' }, - { text: 'Carousel 走马灯', link: '/components/carousel' }, - { text: 'Tag 标签', link: '/components/tag' }, - { text: 'Badge 徽标', link: '/components/badge' }, - { text: 'Avatar 头像', link: '/components/avatar' }, - { text: 'Image 图片', link: '/components/image' }, - { text: 'Pagination 分页', link: '/components/pagination' }, - { text: 'Progress 进度条', link: '/components/progress' }, - { text: 'Statistic 统计数值', link: '/components/statistic' }, - { text: 'Descriptions 描述列表', link: '/components/descriptions' }, - { text: 'Empty 空状态', link: '/components/empty' }, - { text: 'Result 结果', link: '/components/result' }, - { text: 'Timeline 时间轴', link: '/components/timeline' }, - { text: 'QRCode 二维码', link: '/components/qrcode' }, - { text: 'ImagePreview 图片预览', link: '/components/image-preview' }, - { text: 'VirtualList 虚拟列表', link: '/components/virtual-list' }, + { text: "Table 表格", link: "/components/table" }, + { text: "List 列表", link: "/components/list" }, + { text: "Card 卡片", link: "/components/card" }, + { text: "Carousel 走马灯", link: "/components/carousel" }, + { text: "Tag 标签", link: "/components/tag" }, + { text: "Badge 徽标", link: "/components/badge" }, + { text: "Avatar 头像", link: "/components/avatar" }, + { text: "Image 图片", link: "/components/image" }, + { text: "Pagination 分页", link: "/components/pagination" }, + { text: "Progress 进度条", link: "/components/progress" }, + { text: "Statistic 统计数值", link: "/components/statistic" }, + { text: "Descriptions 描述列表", link: "/components/descriptions" }, + { text: "Empty 空状态", link: "/components/empty" }, + { text: "Result 结果", link: "/components/result" }, + { text: "Timeline 时间轴", link: "/components/timeline" }, + { text: "QRCode 二维码", link: "/components/qrcode" }, + { + text: "ImagePreview 图片预览", + link: "/components/image-preview", + }, + { text: "VirtualList 虚拟列表", link: "/components/virtual-list" }, ], }, { - text: '导航', + text: "导航", collapsed: false, items: [ - { text: 'Tabs 标签页', link: '/components/tabs' }, - { text: 'Menu 菜单', link: '/components/menu' }, - { text: 'Breadcrumb 面包屑', link: '/components/breadcrumb' }, - { text: 'Dropdown 下拉菜单', link: '/components/dropdown' }, - { text: 'Steps 步骤条', link: '/components/steps' }, - { text: 'Collapse 折叠面板', link: '/components/collapse' }, - { text: 'Tree 树形控件', link: '/components/tree' }, - { text: 'Anchor 锚点', link: '/components/anchor' }, - { text: 'Affix 固钉', link: '/components/affix' }, - { text: 'BackTop 回到顶部', link: '/components/back-top' }, - { text: 'FloatButton 悬浮按钮', link: '/components/float-button' }, + { text: "Tabs 标签页", link: "/components/tabs" }, + { text: "Menu 菜单", link: "/components/menu" }, + { text: "Breadcrumb 面包屑", link: "/components/breadcrumb" }, + { text: "Dropdown 下拉菜单", link: "/components/dropdown" }, + { text: "Steps 步骤条", link: "/components/steps" }, + { text: "Collapse 折叠面板", link: "/components/collapse" }, + { text: "Tree 树形控件", link: "/components/tree" }, + { text: "Anchor 锚点", link: "/components/anchor" }, + { text: "Affix 固钉", link: "/components/affix" }, + { text: "BackTop 回到顶部", link: "/components/back-top" }, + { text: "FloatButton 悬浮按钮", link: "/components/float-button" }, ], }, ], }, - socialLinks: [{ icon: 'github', link: 'https://github.com/your-org/nova-ui' }], + socialLinks: [ + { icon: "github", link: "https://github.com/your-org/nova-ui" }, + ], }, vite: { plugins: [react()], }, -}) +}); diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index e86f138..4b0558f 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -1,5 +1,5 @@ :root { - --vp-font-family-base: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif; + --vp-font-family-base: "Inter", "PingFang SC", "Microsoft YaHei", sans-serif; --vp-c-brand-1: #2459ff; --vp-c-brand-2: #1d48d6; --vp-c-brand-3: #1a3cae; @@ -83,7 +83,9 @@ padding: 10px 10px 10px 12px; min-width: 2.75rem; text-align: right; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + monospace; font-size: 13px; line-height: 1.55; color: var(--vp-c-text-3); @@ -111,7 +113,9 @@ .live-code-editor-live pre { margin: 0 !important; padding: 10px 12px !important; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace !important; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + monospace !important; font-size: 13px !important; line-height: 1.55 !important; tab-size: 2; @@ -145,7 +149,9 @@ padding: 14px; background: var(--vp-c-bg-soft); text-decoration: none; - transition: border-color 0.2s ease, transform 0.2s ease; + transition: + border-color 0.2s ease, + transform 0.2s ease; } .comp-overview-card:hover { diff --git a/docs/components/form.md b/docs/components/form.md index 6bd8b35..6844044 100644 --- a/docs/components/form.md +++ b/docs/components/form.md @@ -10,12 +10,12 @@ ### 无障碍要点 -| 项 | 说明 | -| --- | --- | -| 分组 | 有 `label` 时根节点 **`role="group"`** + **`aria-labelledby`** | -| 控件 | **`label` `htmlFor`** 与控件 **`id`**;**`aria-describedby`** 串联 help / 错误 / 状态 / **`extra`** | -| 错误 | 校验失败时控件 **`aria-invalid`**,错误文案 **`role="alert"`** | -| 测试钩子 | 根节点带 **`data-nova-form-item="true"`**(与 `Button` 的 `data-nova-button` 一致,便于 E2E) | +| 项 | 说明 | +| -------- | --------------------------------------------------------------------------------------------------- | +| 分组 | 有 `label` 时根节点 **`role="group"`** + **`aria-labelledby`** | +| 控件 | **`label` `htmlFor`** 与控件 **`id`**;**`aria-describedby`** 串联 help / 错误 / 状态 / **`extra`** | +| 错误 | 校验失败时控件 **`aria-invalid`**,错误文案 **`role="alert"`** | +| 测试钩子 | 根节点带 **`data-nova-form-item="true"`**(与 `Button` 的 `data-nova-button` 一致,便于 E2E) | ## 示例 @@ -51,34 +51,34 @@ 继承除 **`onSubmit`** 以外的 [`FormHTMLAttributes`](https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLFormElement)(如 `className`、`autoComplete` 等)。 -| 属性 | 说明 | 类型 | 默认值 | -| --- | --- | --- | --- | -| initialValues | 各字段初始值 | `Record` | - | -| validateTrigger | 校验触发时机 | `'onSubmit' \| 'onChange' \| 'onBlur'` | `'onSubmit'` | -| onSubmit | 校验通过后提交;入参为当前表单值快照 | `(values: Record) => void` | - | +| 属性 | 说明 | 类型 | 默认值 | +| --------------- | ------------------------------------ | ------------------------------------------- | ------------ | +| initialValues | 各字段初始值 | `Record` | - | +| validateTrigger | 校验触发时机 | `'onSubmit' \| 'onChange' \| 'onBlur'` | `'onSubmit'` | +| onSubmit | 校验通过后提交;入参为当前表单值快照 | `(values: Record) => void` | - | ### FormItem -| 属性 | 说明 | 类型 | 默认值 | -| --- | --- | --- | --- | -| name | 字段名(唯一键) | `string` | - | -| label | 标签 | `ReactNode` | - | -| rules | 验证规则列表 | `FormRule[]` | - | -| dependencies | 依赖字段(变更时触发当前项重算,与规则 `deps` 等配合) | `string[]` | - | -| requiredMark | 在 `label` 存在时,若规则含 `required` 是否显示红色 `*` | `boolean` | `true` | -| help | 无校验错误时显示的说明文案;有 `error` 时优先显示错误 | `ReactNode` | - | -| extra | 辅助区域(在错误/帮助文案之后);带稳定 **`id`**,并入控件 **`aria-describedby`** | `ReactNode` | - | -| validateStatus | 人工提示态(与 `rules` 触发的 `error` 文案独立);渲染 `success` / `warning` 固定文案,并纳入控件 **`aria-describedby`**;`error` 仅占类型位,无单独 UI | `'error' \| 'success' \| 'warning'` | - | -| children | 表单控件(需与 `name` 对应的值收集方式兼容);为单个子元素时注入 `value` / `onChange` / `onBlur`,并与 **`label` 通过 `id` + `htmlFor` 关联**;**`help` / 校验错误 / `validateStatus` / `extra`** 写入 **`aria-describedby`**(空格分隔,顺序:`help`/校验错误 → `validateStatus` → `extra`);有错时 **`aria-invalid`** 与错误 **`role="alert"`**(子元素已有非空 `id` 时保留) | `ReactNode` | - | +| 属性 | 说明 | 类型 | 默认值 | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------ | +| name | 字段名(唯一键) | `string` | - | +| label | 标签 | `ReactNode` | - | +| rules | 验证规则列表 | `FormRule[]` | - | +| dependencies | 依赖字段(变更时触发当前项重算,与规则 `deps` 等配合) | `string[]` | - | +| requiredMark | 在 `label` 存在时,若规则含 `required` 是否显示红色 `*` | `boolean` | `true` | +| help | 无校验错误时显示的说明文案;有 `error` 时优先显示错误 | `ReactNode` | - | +| extra | 辅助区域(在错误/帮助文案之后);带稳定 **`id`**,并入控件 **`aria-describedby`** | `ReactNode` | - | +| validateStatus | 人工提示态(与 `rules` 触发的 `error` 文案独立);渲染 `success` / `warning` 固定文案,并纳入控件 **`aria-describedby`**;`error` 仅占类型位,无单独 UI | `'error' \| 'success' \| 'warning'` | - | +| children | 表单控件(需与 `name` 对应的值收集方式兼容);为单个子元素时注入 `value` / `onChange` / `onBlur`,并与 **`label` 通过 `id` + `htmlFor` 关联**;**`help` / 校验错误 / `validateStatus` / `extra`** 写入 **`aria-describedby`**(空格分隔,顺序:`help`/校验错误 → `validateStatus` → `extra`);有错时 **`aria-invalid`** 与错误 **`role="alert"`**(子元素已有非空 `id` 时保留) | `ReactNode` | - | ### FormRule -| 属性 | 说明 | 类型 | 默认值 | -| --- | --- | --- | --- | -| required | 必填 | `boolean` | - | -| minLength / maxLength | 字符串长度 | `number` | - | -| pattern | 正则 | `RegExp` | - | -| when | 是否启用本条规则(依赖整表值) | `(values: Record) => boolean` | - | -| deps | 依赖字段(细则实现见源码) | `string[]` | - | -| message | 错误文案 | `string` | - | -| validator | 自定义校验,返回 `null` 表示通过 | `(value: unknown, values: Record) => string \| null` | - | +| 属性 | 说明 | 类型 | 默认值 | +| --------------------- | -------------------------------- | --------------------------------------------------------------------- | ------ | +| required | 必填 | `boolean` | - | +| minLength / maxLength | 字符串长度 | `number` | - | +| pattern | 正则 | `RegExp` | - | +| when | 是否启用本条规则(依赖整表值) | `(values: Record) => boolean` | - | +| deps | 依赖字段(细则实现见源码) | `string[]` | - | +| message | 错误文案 | `string` | - | +| validator | 自定义校验,返回 `null` 表示通过 | `(value: unknown, values: Record) => string \| null` | - | diff --git a/docs/guide/accessibility.md b/docs/guide/accessibility.md new file mode 100644 index 0000000..d7b8d4f --- /dev/null +++ b/docs/guide/accessibility.md @@ -0,0 +1,78 @@ +# 可访问性(a11y)检查清单与审计 + +## 检查清单 + +### 1) 键盘导航 +- 组件可通过 `Tab` 到达关键交互点。 +- 弹出层支持 `Esc` 关闭(Modal / Dropdown)。 +- 菜单类支持方向键浏览(Menu / Dropdown 列表)。 +- 当前激活项可通过 `tabIndex` 或等价机制感知。 + +### 2) 焦点管理 +- 打开浮层后焦点进入浮层内部(优先落到首个可操作元素)。 +- 关闭浮层后焦点返回触发源。 +- 不出现“焦点丢失”或跳到页面顶部的问题。 + +### 3) 语义标签与 ARIA +- Modal 使用 `role="dialog"` + `aria-modal="true"`。 +- 触发器使用 `aria-expanded`、`aria-haspopup`、`aria-controls` 关联弹层。 +- 表单错误态暴露 `aria-invalid`,错误文案通过 `aria-describedby` 绑定。 +- 文本标签通过 `label[for]` 绑定表单控件。 + +## 组件级审计结果(本轮) + +| 组件 | 结果 | 说明 | +|---|---|---| +| Modal | 通过 | 已补齐标题关联与焦点进出管理 | +| Dropdown | 通过 | 已补齐键盘展开/关闭、方向键遍历、触发器 ARIA | +| Menu | 通过 | 已补齐方向键遍历与活动项焦点语义 | +| Select | 通过 | 原生 `select` + `label` 绑定已满足基础可访问性 | +| Form(含表单控件接入) | 通过 | 已补齐 `label-for`、`aria-invalid`、`aria-describedby` | + +## 修复明细(问题 - 改法 - 验证方式) + +### Modal +- **问题**:打开后焦点未自动进入弹窗;关闭后未恢复到原焦点;标题未通过 `aria-labelledby` 绑定。 +- **改法**: + - 打开时记录 `document.activeElement`,并将焦点移动到关闭按钮。 + - 关闭(卸载)时恢复到打开前焦点。 + - 为标题生成稳定 `id` 并通过 `aria-labelledby` 关联 `role="dialog"`。 +- **验证方式**: + - 键盘触发打开 Modal 后,焦点落在关闭按钮。 + - 按 `Esc` 或点击关闭后,焦点回到触发按钮。 + - 用浏览器无障碍树检查 `dialog` 的可访问名称来源于标题。 + +### Dropdown +- **问题**:仅鼠标可用;触发器缺少展开状态语义;缺少 `Esc` 与方向键导航;点击外部不收起会影响键盘流。 +- **改法**: + - 触发按钮添加 `aria-expanded`、`aria-haspopup="menu"`、`aria-controls`。 + - 支持触发器 `Enter / Space / ArrowDown` 打开。 + - 菜单支持 `ArrowUp/ArrowDown` 切换活动项,`Esc` 关闭并将焦点还给触发器。 + - 增加 click outside 关闭行为。 +- **验证方式**: + - 仅键盘完成“打开 -> 切换项 -> 关闭”。 + - 用读屏检查触发器可读到展开状态。 + +### Menu +- **问题**:缺少方向键导航,活动项缺少 roving 焦点语义。 +- **改法**: + - 在 `menu` 层监听方向键(垂直:上下;水平:左右)。 + - 维护活动项 key,并为活动项设置 `tabIndex=0`,其他为 `-1`。 +- **验证方式**: + - 通过方向键在菜单项间循环切换。 + - Tab 进入菜单后,焦点落在当前活动项。 + +### Form / 表单控件接入 +- **问题**:`FormItem` 标签与输入控件缺少强绑定;错误信息未与控件语义关联。 +- **改法**: + - `FormItem` 生成控件 id,`label` 使用 `htmlFor` 关联。 + - 有错误时向控件注入 `aria-invalid` 与 `aria-describedby`,并给错误文本分配 id。 +- **验证方式**: + - 点击 `label` 可聚焦对应控件。 + - 触发校验错误后,读屏可读到错误状态及错误文案。 + +## 行为变更影响范围 + +- Dropdown / Menu 新增键盘方向键行为,可能影响依赖自定义 `onKeyDown` 冒泡逻辑的页面。 +- Modal 打开后会自动聚焦关闭按钮,关闭后恢复先前焦点;如果业务依赖“打开不夺焦”,需评估。 +- 以上变更均未修改公开 API,属于行为增强。 diff --git a/packages/ui/src/components/_internal/DialogHeader/DialogHeader.tsx b/packages/ui/src/components/_internal/DialogHeader/DialogHeader.tsx index 451a97f..d0f0698 100644 --- a/packages/ui/src/components/_internal/DialogHeader/DialogHeader.tsx +++ b/packages/ui/src/components/_internal/DialogHeader/DialogHeader.tsx @@ -1,9 +1,11 @@ -import type { ReactNode } from 'react' +import type { ReactNode, RefObject } from 'react' import { cn } from '../../../utils/cn' export interface DialogHeaderProps { title?: ReactNode + titleId?: string + closeButtonRef?: RefObject onClose?: () => void /** 外层容器额外 class(如 mb-3 / mb-4) */ contentClassName?: string @@ -12,14 +14,19 @@ export interface DialogHeaderProps { export function DialogHeader({ title, + titleId, + closeButtonRef, onClose, contentClassName, closeAriaLabel = 'Close', }: DialogHeaderProps) { return (
-

{title}

+

+ {title} +

+ + + ) : null} + {dots && count > 1 ? ( +
+ {items.map((_, i) => ( +
+ ) : null} +
+ ) +}) diff --git a/packages/ui/src/components/data/Image.tsx b/packages/ui/src/components/data/Image.tsx new file mode 100644 index 0000000..affb5b5 --- /dev/null +++ b/packages/ui/src/components/data/Image.tsx @@ -0,0 +1,57 @@ +import { forwardRef, useState, type ImgHTMLAttributes, type ReactNode } from 'react' +import { cn } from '../../utils/cn' + +export interface ImageProps extends ImgHTMLAttributes { + fallback?: string + placeholder?: ReactNode + preview?: boolean +} + +export const Image = forwardRef(function Image( + { + className, + fallback = '', + placeholder, + preview = true, + src, + alt = '', + onError, + ...props + }, + ref, +) { + const [failed, setFailed] = useState(false) + const [loaded, setLoaded] = useState(false) + const [previewOpen, setPreviewOpen] = useState(false) + + const imgSrc = failed ? fallback : src + + return ( + <> + + {!loaded && placeholder ? {placeholder} : null} + {alt} setLoaded(true)} + onError={(e) => { + setFailed(true) + onError?.(e) + }} + onClick={preview ? () => setPreviewOpen(true) : undefined} + className={cn('block max-w-full', preview && 'cursor-pointer', !loaded && 'opacity-0')} + {...props} + /> + + {previewOpen ? ( +
setPreviewOpen(false)} + > + {alt} +
+ ) : null} + + ) +}) diff --git a/packages/ui/src/components/data/List.tsx b/packages/ui/src/components/data/List.tsx new file mode 100644 index 0000000..1c1245c --- /dev/null +++ b/packages/ui/src/components/data/List.tsx @@ -0,0 +1,80 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react' +import { cn } from '../../utils/cn' + +export interface ListItem { + key: string | number + content: ReactNode + avatar?: ReactNode + title?: ReactNode + description?: ReactNode + extra?: ReactNode +} + +export interface ListProps extends HTMLAttributes { + dataSource?: ListItem[] + header?: ReactNode + footer?: ReactNode + bordered?: boolean + size?: 'sm' | 'md' | 'lg' + loading?: boolean + renderItem?: (item: ListItem, index: number) => ReactNode + grid?: { cols?: number; gap?: number } +} + +export const List = forwardRef(function List( + { + className, + dataSource = [], + header, + footer, + bordered = true, + size = 'md', + loading = false, + renderItem, + grid, + ...props + }, + ref, +) { + const paddingCls = { 'px-3 py-2': size === 'sm', 'px-4 py-3': size === 'md', 'px-6 py-4': size === 'lg' } + + const defaultRender = (item: ListItem) => ( +
+ {item.avatar ?
{item.avatar}
: null} +
+ {item.title ?
{item.title}
: null} + {item.description ?
{item.description}
: null} + {item.content} +
+ {item.extra ?
{item.extra}
: null} +
+ ) + + return ( +
+ {header ?
{header}
: null} + {loading ? ( +
Loading...
+ ) : grid ? ( +
+ {dataSource.map((item, i) => ( +
{renderItem ? renderItem(item, i) : defaultRender(item)}
+ ))} +
+ ) : ( +
    + {dataSource.map((item, i) => ( +
  • + {renderItem ? renderItem(item, i) : defaultRender(item)} +
  • + ))} +
+ )} + {footer ?
{footer}
: null} +
+ ) +}) diff --git a/packages/ui/src/components/feedback/Alert.tsx b/packages/ui/src/components/feedback/Alert.tsx new file mode 100644 index 0000000..a1e5c3d --- /dev/null +++ b/packages/ui/src/components/feedback/Alert.tsx @@ -0,0 +1,81 @@ +import { forwardRef, useState, type HTMLAttributes, type ReactNode } from 'react' +import { cn } from '../../utils/cn' + +export interface AlertProps extends HTMLAttributes { + type?: 'info' | 'success' | 'warning' | 'error' + message: ReactNode + description?: ReactNode + closable?: boolean + showIcon?: boolean + icon?: ReactNode + onClose?: () => void + banner?: boolean + action?: ReactNode +} + +const typeIconMap: Record = { + info: 'ℹ️', + success: '✅', + warning: '⚠️', + error: '❌', +} + +export const Alert = forwardRef(function Alert( + { + className, + type = 'info', + message, + description, + closable = false, + showIcon = true, + icon, + onClose, + banner = false, + action, + ...props + }, + ref, +) { + const [visible, setVisible] = useState(true) + + if (!visible) return null + + return ( +
+ {showIcon ? {icon ?? typeIconMap[type]} : null} +
+
{message}
+ {description ?
{description}
: null} +
+ {action ?
{action}
: null} + {closable ? ( + + ) : null} +
+ ) +}) diff --git a/packages/ui/src/components/feedback/Popconfirm.tsx b/packages/ui/src/components/feedback/Popconfirm.tsx new file mode 100644 index 0000000..92e7cf5 --- /dev/null +++ b/packages/ui/src/components/feedback/Popconfirm.tsx @@ -0,0 +1,89 @@ +import { forwardRef, useState, useRef, useEffect, type HTMLAttributes, type ReactNode, type ReactElement } from 'react' +import { cn } from '../../utils/cn' + +export interface PopconfirmProps extends Omit, 'title'> { + title: ReactNode + description?: ReactNode + open?: boolean + defaultOpen?: boolean + onConfirm?: () => void + onCancel?: () => void + onOpenChange?: (open: boolean) => void + okText?: string + cancelText?: string + children: ReactElement +} + +export const Popconfirm = forwardRef(function Popconfirm( + { + className, + title, + description, + open: controlledOpen, + defaultOpen = false, + onConfirm, + onCancel, + onOpenChange, + okText = '确定', + cancelText = '取消', + children, + ...props + }, + ref, +) { + const [internalOpen, setInternalOpen] = useState(defaultOpen) + const isOpen = controlledOpen ?? internalOpen + const wrapperRef = useRef(null) + + const setOpen = (val: boolean) => { + setInternalOpen(val) + onOpenChange?.(val) + } + + useEffect(() => { + if (!isOpen) return + const handler = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }) + + return ( +
+
setOpen(!isOpen)}>{children}
+ {isOpen ? ( +
+
+ ⚠️ +
+
{title}
+ {description ?
{description}
: null} +
+
+
+ + +
+
+ ) : null} +
+ ) +}) diff --git a/packages/ui/src/components/feedback/Skeleton.tsx b/packages/ui/src/components/feedback/Skeleton.tsx new file mode 100644 index 0000000..13a9a88 --- /dev/null +++ b/packages/ui/src/components/feedback/Skeleton.tsx @@ -0,0 +1,49 @@ +import { forwardRef, type HTMLAttributes } from 'react' +import { cn } from '../../utils/cn' + +export interface SkeletonProps extends Omit, 'title'> { + active?: boolean + avatar?: boolean + title?: boolean + paragraph?: boolean | { rows?: number } + loading?: boolean +} + +export const Skeleton = forwardRef(function Skeleton( + { + className, + active = true, + avatar = false, + title = true, + paragraph = true, + loading = true, + children, + ...props + }, + ref, +) { + if (!loading) return <>{children} + + const rows = typeof paragraph === 'object' ? (paragraph.rows ?? 3) : paragraph ? 3 : 0 + const animCls = active ? 'animate-pulse' : '' + + return ( +
+ {avatar ?
: null} +
+ {title ?
: null} + {rows > 0 ? ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ ))} +
+ ) : null} +
+
+ ) +}) diff --git a/packages/ui/src/components/feedback/Spin.tsx b/packages/ui/src/components/feedback/Spin.tsx new file mode 100644 index 0000000..5666377 --- /dev/null +++ b/packages/ui/src/components/feedback/Spin.tsx @@ -0,0 +1,45 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react' +import { cn } from '../../utils/cn' + +export interface SpinProps extends HTMLAttributes { + spinning?: boolean + size?: 'sm' | 'md' | 'lg' + tip?: ReactNode +} + +export const Spin = forwardRef(function Spin( + { className, spinning = true, size = 'md', tip, children, ...props }, + ref, +) { + const spinner = ( +
+ + {tip ? {tip} : null} +
+ ) + + if (!children) { + return spinning ? ( +
+ {spinner} +
+ ) : null + } + + return ( +
+ {children} + {spinning ? ( +
+ {spinner} +
+ ) : null} +
+ ) +}) diff --git a/packages/ui/src/components/feedback/overlays/Modal/Modal.test.tsx b/packages/ui/src/components/feedback/overlays/Modal/Modal.test.tsx index 48b3653..e5928eb 100644 --- a/packages/ui/src/components/feedback/overlays/Modal/Modal.test.tsx +++ b/packages/ui/src/components/feedback/overlays/Modal/Modal.test.tsx @@ -26,7 +26,11 @@ describe('Modal', () => { const dialog = await waitFor(() => screen.getByRole('dialog')) expect(dialog).toHaveAttribute('aria-modal', 'true') - expect(dialog).toHaveAttribute('aria-label', 'Hello') + expect(dialog).toHaveAccessibleName('Hello') + expect(dialog).toHaveAttribute( + 'aria-labelledby', + screen.getByRole('heading', { name: 'Hello' }).id, + ) expect(screen.getByText('Modal body')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument() }) @@ -41,7 +45,11 @@ describe('Modal', () => { ) await waitFor(() => expect(screen.getByRole('dialog', { name: 'T' })).toBeInTheDocument()) - fireEvent.click(within(screen.getByRole('dialog', { name: 'T' })).getByRole('button', { name: 'Close modal' })) + fireEvent.click( + within(screen.getByRole('dialog', { name: 'T' })).getByRole('button', { + name: 'Close modal', + }), + ) expect(onClose).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/ui/src/components/feedback/overlays/Modal/Modal.tsx b/packages/ui/src/components/feedback/overlays/Modal/Modal.tsx index eb646c6..1aa0645 100644 --- a/packages/ui/src/components/feedback/overlays/Modal/Modal.tsx +++ b/packages/ui/src/components/feedback/overlays/Modal/Modal.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useRef } from 'react' +import { forwardRef, useEffect, useRef } from 'react' import { Portal } from '../../../../utils/portal' import { useEscapeKey } from '../../../../hooks/useEscapeKey' @@ -12,16 +12,36 @@ export const Modal = forwardRef(function Modal( _ref, ) { const panelRef = useRef(null) + const closeButtonRef = useRef(null) + const lastActiveElementRef = useRef(null) useEscapeKey(() => onClose?.(), open) useClickOutside(panelRef, () => onClose?.(), open) + useEffect(() => { + if (!open) return + + lastActiveElementRef.current = document.activeElement as HTMLElement | null + closeButtonRef.current?.focus() + + return () => { + lastActiveElementRef.current?.focus() + } + }, [open]) + if (!open) { return null } return ( - + {children} diff --git a/packages/ui/src/components/feedback/overlays/Modal/parts/ModalDialog.tsx b/packages/ui/src/components/feedback/overlays/Modal/parts/ModalDialog.tsx index bb4d37d..8ff3bfa 100644 --- a/packages/ui/src/components/feedback/overlays/Modal/parts/ModalDialog.tsx +++ b/packages/ui/src/components/feedback/overlays/Modal/parts/ModalDialog.tsx @@ -1,10 +1,11 @@ -import { type HTMLAttributes, type ReactNode, type RefObject } from 'react' +import { type HTMLAttributes, type ReactNode, type RefObject, useId } from 'react' import { DialogHeader } from '../../../../_internal/DialogHeader' import { cn } from '../../../../../utils/cn' export interface ModalDialogProps { panelRef: RefObject + closeButtonRef: RefObject title?: string onClose?: () => void className?: string @@ -12,18 +13,41 @@ export interface ModalDialogProps { dialogProps: Omit, 'children'> } -export function ModalDialog({ panelRef, title, onClose, className, children, dialogProps }: ModalDialogProps) { +export function ModalDialog({ + panelRef, + closeButtonRef, + title, + onClose, + className, + children, + dialogProps, +}: ModalDialogProps) { + const titleId = useId() + return ( -
+
- +
{children}
diff --git a/packages/ui/src/components/form/AutoComplete.tsx b/packages/ui/src/components/form/AutoComplete.tsx new file mode 100644 index 0000000..0b92ac1 --- /dev/null +++ b/packages/ui/src/components/form/AutoComplete.tsx @@ -0,0 +1,103 @@ +import { forwardRef, useState, useRef, useEffect, type InputHTMLAttributes } from 'react' +import { cn } from '../../utils/cn' + +export interface AutoCompleteOption { + value: string + label?: string +} + +export interface AutoCompleteProps extends Omit, 'onChange' | 'onSelect'> { + options?: AutoCompleteOption[] + value?: string + defaultValue?: string + onChange?: (value: string) => void + onSelect?: (value: string) => void + filterOption?: boolean | ((input: string, option: AutoCompleteOption) => boolean) + allowClear?: boolean +} + +export const AutoComplete = forwardRef(function AutoComplete( + { + className, + options = [], + value: controlledValue, + defaultValue = '', + onChange, + onSelect, + filterOption = true, + allowClear = false, + placeholder, + ...props + }, + ref, +) { + const [internal, setInternal] = useState(defaultValue) + const [open, setOpen] = useState(false) + const wrapperRef = useRef(null) + const val = controlledValue ?? internal + + const filtered = options.filter((opt) => { + if (!filterOption) return true + if (typeof filterOption === 'function') return filterOption(val, opt) + return (opt.label ?? opt.value).toLowerCase().includes(val.toLowerCase()) + }) + + const update = (v: string) => { + setInternal(v) + onChange?.(v) + } + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }) + + return ( +
+
+ { + update(e.target.value) + setOpen(true) + }} + onFocus={() => setOpen(true)} + className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100" + {...props} + /> + {allowClear && val ? ( + + ) : null} +
+ {open && filtered.length > 0 ? ( +
    + {filtered.map((opt) => ( +
  • { + update(opt.value) + onSelect?.(opt.value) + setOpen(false) + }} + > + {opt.label ?? opt.value} +
  • + ))} +
+ ) : null} +
+ ) +}) diff --git a/packages/ui/src/components/form/InputNumber.tsx b/packages/ui/src/components/form/InputNumber.tsx new file mode 100644 index 0000000..91efe1e --- /dev/null +++ b/packages/ui/src/components/form/InputNumber.tsx @@ -0,0 +1,101 @@ +import { forwardRef, useState, type HTMLAttributes } from 'react' +import { cn } from '../../utils/cn' + +export interface InputNumberProps extends Omit, 'onChange'> { + value?: number + defaultValue?: number + min?: number + max?: number + step?: number + disabled?: boolean + size?: 'sm' | 'md' | 'lg' + controls?: boolean + precision?: number + placeholder?: string + onChange?: (value: number | null) => void +} + +export const InputNumber = forwardRef(function InputNumber( + { + className, + value: controlledValue, + defaultValue, + min = -Infinity, + max = Infinity, + step = 1, + disabled = false, + size = 'md', + controls = true, + precision, + placeholder, + onChange, + ...props + }, + ref, +) { + const [internal, setInternal] = useState(defaultValue ?? null) + const val = controlledValue ?? internal + + const clamp = (n: number) => { + let v = Math.max(min, Math.min(max, n)) + if (precision !== undefined) v = Number(v.toFixed(precision)) + return v + } + + const update = (n: number | null) => { + const clamped = n !== null ? clamp(n) : null + setInternal(clamped) + onChange?.(clamped) + } + + const heightCls = { 'h-8': size === 'sm', 'h-10': size === 'md', 'h-11': size === 'lg' } + + return ( +
+ {controls ? ( + + ) : null} + { + const raw = e.target.value + if (raw === '' || raw === '-') { update(null); return } + const n = Number(raw) + if (!isNaN(n)) update(n) + }} + onBlur={() => { if (val !== null) update(clamp(val)) }} + className="w-16 min-w-0 flex-1 bg-transparent px-2 text-center text-sm outline-none dark:text-slate-100" + /> + {controls ? ( + + ) : null} +
+ ) +}) diff --git a/packages/ui/src/components/form/TimePicker.tsx b/packages/ui/src/components/form/TimePicker.tsx new file mode 100644 index 0000000..25b7002 --- /dev/null +++ b/packages/ui/src/components/form/TimePicker.tsx @@ -0,0 +1,109 @@ +import { forwardRef, useState, useRef, useEffect, type HTMLAttributes } from 'react' +import { cn } from '../../utils/cn' + +export interface TimePickerProps extends Omit, 'onChange'> { + value?: string + defaultValue?: string + format?: '12' | '24' + disabled?: boolean + placeholder?: string + onChange?: (value: string) => void + size?: 'sm' | 'md' | 'lg' +} + +export const TimePicker = forwardRef(function TimePicker( + { + className, + value: controlledValue, + defaultValue = '', + format = '24', + disabled = false, + placeholder = '选择时间', + onChange, + size = 'md', + ...props + }, + ref, +) { + const [internal, setInternal] = useState(defaultValue) + const [open, setOpen] = useState(false) + const wrapperRef = useRef(null) + const val = controlledValue ?? internal + + const update = (v: string) => { + setInternal(v) + onChange?.(v) + } + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }) + + const hours = Array.from({ length: format === '24' ? 24 : 12 }, (_, i) => format === '24' ? i : i + 1) + const minutes = Array.from({ length: 60 }, (_, i) => i) + + const [selH, selM] = val ? val.split(':').map(Number) : [null, null] + + const heightCls = { 'h-8': size === 'sm', 'h-10': size === 'md', 'h-11': size === 'lg' } + + return ( +
+
!disabled && setOpen(!open)} + > + {val || placeholder} + 🕐 +
+ {open ? ( +
+
+ {hours.map((h) => ( +
{ + const m = selM ?? 0 + update(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`) + }} + > + {String(h).padStart(2, '0')} +
+ ))} +
+
+ {minutes.map((m) => ( +
{ + const h = selH ?? 0 + update(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`) + setOpen(false) + }} + > + {String(m).padStart(2, '0')} +
+ ))} +
+
+ ) : null} +
+ ) +}) diff --git a/packages/ui/src/components/layout/Flex.tsx b/packages/ui/src/components/layout/Flex.tsx new file mode 100644 index 0000000..d8be23c --- /dev/null +++ b/packages/ui/src/components/layout/Flex.tsx @@ -0,0 +1,67 @@ +import { forwardRef, type HTMLAttributes } from 'react' +import { cn } from '../../utils/cn' + +export interface FlexProps extends HTMLAttributes { + direction?: 'row' | 'column' | 'row-reverse' | 'column-reverse' + align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline' + justify?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly' + wrap?: boolean + gap?: number | string + vertical?: boolean +} + +const alignMap: Record = { + start: 'items-start', + center: 'items-center', + end: 'items-end', + stretch: 'items-stretch', + baseline: 'items-baseline', +} + +const justifyMap: Record = { + start: 'justify-start', + center: 'justify-center', + end: 'justify-end', + between: 'justify-between', + around: 'justify-around', + evenly: 'justify-evenly', +} + +export const Flex = forwardRef(function Flex( + { + className, + direction, + align, + justify, + wrap = false, + gap, + vertical = false, + style, + ...props + }, + ref, +) { + const dir = direction ?? (vertical ? 'column' : 'row') + const dirCls: Record = { + row: 'flex-row', + column: 'flex-col', + 'row-reverse': 'flex-row-reverse', + 'column-reverse': 'flex-col-reverse', + } + + return ( +
+ ) +}) diff --git a/packages/ui/src/components/navigation/Dropdown/Dropdown.tsx b/packages/ui/src/components/navigation/Dropdown/Dropdown.tsx index 90e2a13..5005b7e 100644 --- a/packages/ui/src/components/navigation/Dropdown/Dropdown.tsx +++ b/packages/ui/src/components/navigation/Dropdown/Dropdown.tsx @@ -1,4 +1,15 @@ -import { forwardRef, type HTMLAttributes, type ReactNode, useState } from 'react' +import { + forwardRef, + type HTMLAttributes, + type KeyboardEvent, + type ReactNode, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react' +import { useClickOutside } from '../../../hooks/useClickOutside' import { cn } from '../../../utils/cn' export interface DropdownOption { @@ -32,7 +43,13 @@ export const Dropdown = forwardRef(function Dropd ref, ) { const [innerOpen, setInnerOpen] = useState(defaultOpen) + const [activeIndex, setActiveIndex] = useState(-1) + const rootRef = useRef(null) + const triggerRef = useRef(null) + const optionRefs = useRef(new Map()) + const menuId = useId() const open = controlledOpen ?? innerOpen + const enabledOptions = useMemo(() => options.filter((item) => !item.disabled), [options]) const setOpen = (next: boolean) => { if (controlledOpen === undefined) { @@ -45,18 +62,105 @@ export const Dropdown = forwardRef(function Dropd } } + useClickOutside(rootRef, () => setOpen(false), open) + + const setCombinedRef = (node: HTMLDivElement | null) => { + rootRef.current = node + if (typeof ref === 'function') { + ref(node) + return + } + if (ref) { + ;(ref as { current: HTMLDivElement | null }).current = node + } + } + + useEffect(() => { + if (!open) { + setActiveIndex(-1) + return + } + setActiveIndex(0) + }, [open]) + + const handleTriggerKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setOpen(true) + if (enabledOptions[0]) { + requestAnimationFrame(() => { + optionRefs.current.get(enabledOptions[0].key)?.focus() + }) + } + } + } + + const handleMenuKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false) + triggerRef.current?.focus() + return + } + if (event.key === 'ArrowDown') { + event.preventDefault() + if (enabledOptions.length > 0) { + setActiveIndex((prev) => { + const next = (prev + 1) % enabledOptions.length + requestAnimationFrame(() => { + optionRefs.current.get(enabledOptions[next].key)?.focus() + }) + return next + }) + } + } + if (event.key === 'ArrowUp') { + event.preventDefault() + if (enabledOptions.length > 0) { + setActiveIndex((prev) => { + const next = (prev - 1 + enabledOptions.length) % enabledOptions.length + requestAnimationFrame(() => { + optionRefs.current.get(enabledOptions[next].key)?.focus() + }) + return next + }) + } + } + } + return ( -
- {open ? ( -
    +