Commit 7b1c7ebb authored by Andy-bubu's avatar Andy-bubu

feat: 完成廉洁风险防控鑫元 HeroUI 迁移

parent 3e89ebbc
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
/.umi-production /.umi-production
/.umi-test /.umi-test
/dist /dist
/.playwright-cli
/.mfsu /.mfsu
.swc .swc
.pnpm-store .pnpm-store
...@@ -83,11 +83,26 @@ export default defineConfig({ ...@@ -83,11 +83,26 @@ export default defineConfig({
devtool: false, devtool: false,
}, },
chainWebpack(config, { env }) { chainWebpack(config, { env }) {
// Keep dev-only browser/audit artifacts outside Umi's dependency graph.
// Otherwise Playwright console logs can trigger a CSS rebuild/HMR loop.
config.watchOptions({
ignored: [
'**/artifacts/**',
'**/.playwright-cli/**',
'**/docs/**',
'**/dist/**',
'**/node_modules/**',
],
});
if (env === 'production') { if (env === 'production') {
config.devtool('source-map'); config.devtool('source-map');
} }
}, },
esbuildMinifyIIFE: true, esbuildMinifyIIFE: true,
jsMinifierOptions: {
target: ['chrome80', 'es2020'],
},
define: { define: {
STREAMS_API_URL: '/api', STREAMS_API_URL: '/api',
STREAMS_PARSER_URL: '/parser', STREAMS_PARSER_URL: '/parser',
......
# UI 架构
## 分层
1. 业务控制层:现有 services、hooks、路由、权限、查询参数、分页口径和保存回调保持不变。
2. 页面编排层:页面只组合业务数据与 HeroUI 原生组件,`className` 仅用于布局。
3. 共享组合层:`AppShell`、`DataTable`、`XinyuanAutocomplete`、`XinyuanModal` 等均是 HeroUI-native 的业务无关组合,不兼容旧 Catalyst props。
4. 主题层:HeroUI styles -> HeroUI Pro CSS -> 权威 `theme.css` -> 批准的 `tokens.css` -> Shell 布局样式。
## 依赖边界
- OSS 控件直接来自 `@heroui/react`。
- Pro 控件优先使用组件子路径,例如 `@heroui-pro/react/sidebar` 与 `@heroui-pro/react/data-grid`。
- 不通过旧组件目录转发 HeroUI,不访问内部 slot/BEM 来制作页面私有外观。
- React、React DOM、React Aria 与 HeroUI 只保留一套兼容运行时。
## 状态所有权
- 页面继续拥有查询条件、选择记录、弹窗开关、表单草稿和请求时机。
- HeroUI 组件拥有打开态、焦点、键盘导航、选中态和 Portal 可访问性。
- 移动导航使用 HeroUI Pro Sheet 与 Sidebar compound 内容,并在打开应用切换前关闭移动 Sheet;Dialog 使用明确的可访问名称。
- 应用切换 Modal 的卡片列表和加载骨架共用响应式网格:移动端一列、`sm` 起固定两列、间距 12px,长列表在 Modal Body 内滚动。
- 数据转换仍在现有 service/util 边界完成;UI 迁移不改变接口协议。
- Modal 是独立 Overlay/L1 平面;查看态字段组使用 `Surface secondary`/L2。详情中的关联记录是 L2 内的静态 L3 子区域,使用 HeroUI `Surface tertiary` 包裹共享 `DataTable`,并通过 HeroUI Pro DataGrid 官方 `secondary` variant 区分表头和 L3 背景;页面不得覆盖 DataGrid 的 BEM、slot、背景或圆角。
- 业务列表页面标题由 Shell 的 HeroUI Pro Navbar 统一呈现,`RecordWorkbench` 内容区不再重复渲染同名 `<h2>`,以避免标题重复并让搜索/工具栏保持首屏优先级。
- `DataTable` 是唯一业务表格边界:它负责列宽计算、真实宽表检测、内部滚动、状态类、原生选择、ActionBar、空/加载状态、Pagination 和统一排版 Token;页面只提供业务列与回调。`src/design-system/data-grid.css` 只负责共享几何与排版,不绘制 DataGrid 背景、描边、圆角、阴影或状态。
- `XinyuanModal` 与直接 HeroUI Modal 页面均使用 `rc-dialog-panel--{size}` 共享宽度阶梯;不得在页面里通过颜色、阴影、圆角或旧 BEM class 重画弹窗。
- 弹窗头部说明由 HeroUI `Description` 负责;字段标签由共享 `getFieldDisplayLabel` 从字段定义和中文别名表解析,`metadata.*` 只存在于数据层。文本、选择、日期、人员/引用和附件的提示均由对应 HeroUI 控件在内部展示,禁止再在控件下方复制同一句自动提示。
- 4xl 动态表单由共享 `DynamicRecordDialog` 负责等宽双列编排,字段类型决定是否跨列;独立流程页没有 AppShell 时保留 `RecordWorkbench` 内容标题,主应用列表通过 `showContentTitle={false}` 只使用 Navbar 标题。
- KPI 只能使用 HeroUI Pro `KPI` compound API;共享层不得以 Card class 重新绘制 KPI 的背景、描边、阴影或圆角。
- 所有可截断业务文本由共享 `DataTableText`、`DataTableTextPair` 或 `DataTableSecondaryText` 提供,并以 HeroUI `Tooltip` 暴露完整值;页面不得再写独立截断、换行或原生 `title` 规则。
- 开发工具产物不属于页面依赖图:Umi webpack watcher 在共享配置中忽略 `.playwright-cli`、`docs`、`dist` 和 `artifacts`,保证 HeroUI/Inter 样式不会因验收日志写入而反复 HMR。
## 验收证据
浏览器截图、控制台检查、构建、依赖树和扫描结果已写入 `docs/xinyuan-ui-audit.md`。目标服务保持运行于 `http://localhost:8002/integrity_risk_prevention_new/`;基准服务运行于 `http://localhost:8006/problem_rectification_new/`。业务 API、参数、数据结构、路由、权限、校验、分页口径、保存回调、登录退出和请求时机均未改动;变更集中在 HeroUI 组件结构、共享布局、动作归位、响应式 gutter 和无效 DOM 属性清理。
# UI 迁移清单
状态:`[ ]` 未完成,`[x]` 已完成。
- [x] 创建专用迁移分支。
- [x] 执行官方 `hpsetup` 并验证 Pro 子路径。
- [x] 记录未修改应用的原始启动状态。
- [x] 同步权威 theme、Tokens、Logo 与 Shell 基线。
- [x] Shell 使用 HeroUI Pro Sidebar/Navbar,支持 offcanvas、主题切换、用户菜单与退出。
- [x] 应用切换使用 HeroUI Modal 和移动一列、`sm` 起固定两列应用卡片与加载骨架。
- [x] 登录页使用 HeroUI Card/TextField/Input/Button/Alert。
- [x] 所有活动列表使用 DataGrid、ActionBar、EmptyState、Pagination。
- [x] 全局 DataGrid populated/loading/empty 高度链与宽表内部滚动复核完成;长文本统一最多两行 + HeroUI Tooltip。
- [x] 所有活动筛选使用 Surface/SearchField/Autocomplete/DatePicker/Disclosure。
- [x] 编辑态多选值使用 HeroUI TagGroup 独立展示和删除,同时支持清空全部与下拉单项再次点选移除;单选保留官方清除入口。
- [x] 所有活动弹窗使用 Modal/AlertDialog。
- [x] 所有弹窗头部使用 HeroUI `Description`;字段标签使用中文映射,输入提示只保留在控件内部,不重复渲染字段级自动说明。
- [x] 所有弹窗 Overlay、嵌套 Surface 和 DataGrid 在浅/深色下具有可辨识的 L1/L2/L3 背景;Modal 为 24px、内容 Surface 为 16px 圆角。
- [x] 弹窗查看态为单一 L2 字段组且字段无框,编辑态移除字段组 Surface;只读字段不使用禁用输入框模拟。
- [x] 所有动态表单使用 HeroUI compound 控件。
- [x] 所有上传使用 HeroUI Pro DropZone。
- [x] 活动 Catalyst/Headless UI 引用为零。
- [x] 活动原生 select/date/file 控件为零(DropZone 内部 input 为不可见技术输入)。
- [x] 页面私有视觉 CSS 扫描为零;页面 className 仅承担布局、间距和响应式。
- [x] 全局复扫移除业务 JSX 的 Surface/KPI/DataGrid 圆角覆盖与旧 Disclosure BEM class。
- [x] 共享 DataGrid typography Token 与 `pm-data-grid-*` 层级已落地,引用字段与人员字段继续复用 HeroUI Tooltip composition。
- [x] 原生 `title` 截断提示清理完成;共享选择器、详情副标题、应用切换卡片和表格操作使用 HeroUI Tooltip。
- [x] 全局复核动态 4xl 表单双列、详情编辑控件 `secondary` 层级、共享图标出口和 Pro KPI compound 边界。
- [x] 浅色与深色四视口浏览器验收完成,截图和控制台结果已记录在 `docs/xinyuan-ui-audit.md`。
- [x] `pnpm run build`、`git diff --check`、Skill audit 已执行;audit 的 pnpm store/CJS 子路径警告已人工复核为工具误报。
- [x] Umi watcher 已排除浏览器验收/构建产物;开发态 `umi.css`/Inter 字体 HMR 循环已复测并记录。
剩余说明:业务没有活动图表路由,因此没有凭空添加 KPI/图表数据;`recharts` 仅保留原依赖且无源码 import。应用切换遵循问题整改基准和鑫元规范,移动端一列、`sm` 起固定两列。
# 鑫元设计系统
本项目以“问题整改”应用的实际实现为唯一基准,使用 `@heroui/react` 与 `@heroui-pro/react` 的原生 compound API。
## 权威样式
- `src/design-system/theme.css`:从 Skill 权威基线逐字同步,禁止项目覆盖其核心 Token。
- `src/design-system/tokens.css`:仅包含已批准的鑫元扩展 Token。
- `src/design-system/shell.css`:复用问题整改的业务无关 Shell 布局模式。
- Logo:`xy-logo-red.png` 用于浅色,`xy-logo-white.png` 用于深色。
## 硬规则
- 页面 `className` 只处理布局、宽度、间距、对齐和响应式。
- 组件外观由 HeroUI variant/size/compound API 与权威主题负责。
- 不引入 Catalyst、Headless UI、兼容旧 props 的 HeroUI 包装层或可见原生选择/日期/文件控件。
- 列表使用 HeroUI Pro DataGrid、ActionBar、EmptyState 和 HeroUI Pagination。
- 筛选使用 HeroUI Surface、SearchField、Autocomplete、DatePicker 和 Disclosure。
- 受控 Autocomplete 的已选值必须可逆操作:单选使用官方 `Autocomplete.ClearButton` 清除当前值;多选在 Trigger 内使用官方 `TagGroup + Tag + Tag.RemoveButton` 将每个值显示为独立 Tag 并支持单项删除,同时保留官方清除入口清空集合;下拉中的官方 `ListBox.Item` 再次点选也可移除单项。Tag、清除按钮和选中态都从同一受控 `selectedKeys` 派生,不得合并为逗号文本、使用页面自制标签或维护第二套选择状态。
- KPI 使用 HeroUI Pro KPI;图表使用 HeroUI Pro 图表与 ChartTooltip。
- Modal/Dropdown/Popover/Tooltip 等 Portal 必须继承根级深色主题。
- Modal 面板保持 HeroUI Overlay 的 24px 圆角;弹窗内结构性 Surface 只声明官方 `secondary/tertiary` variant 和布局间距,不再通过业务 JSX 覆盖圆角、背景、描边或阴影。详情中的关联记录属于 L2 详情 Surface 内的静态 L3 内容,使用官方 `Surface variant="tertiary"` 承载,并在其内部使用共享 `DataTable` 的 HeroUI Pro DataGrid `secondary` variant;表头保持 `surface-secondary`、表体保持透明并继承 L3 Surface。禁止页面自定义颜色、描边、阴影或 HeroUI 内部 BEM/slot 外观。
- Modal 统一带有 `rc-dialog-panel rc-dialog-panel--{size}` 宽度阶梯;宽度只由共享布局类负责,背景、圆角、阴影、关闭和状态由 HeroUI Modal compound API 提供。
- Modal 头部必须渲染 HeroUI `Description`,所有业务弹窗都要有一条辅助说明;已有真实业务上下文优先使用字段定义或流程文案,没有上下文时使用共享的展示型默认提示,不在页面里留空或自行造色。
- 字段标签必须使用共享 `getFieldDisplayLabel` 将字段定义别名映射为中文;`metadata.*` 仅是数据路径,禁止直接显示在标签、placeholder、ARIA 文案或列表表头中。未知技术字段统一显示“其他信息”,不得把英文下划线别名暴露给用户。
- 输入提示只出现在控件内部:文本/长文本使用中文 placeholder,选择、日期、人员/引用和附件使用各自 HeroUI 控件的内部提示。禁止在控件下方再渲染同一句自动生成的 `Description`;真实错误、加载、空状态仍分别由 Alert、Spinner、EmptyState 承担。
- 弹窗内编辑字段统一使用 HeroUI `secondary` variant:文本、TextArea、DatePicker、Autocomplete(含普通选项、人员、引用字段)必须共享 L2 字段背景与 disabled 状态,不得因组合组件漏传 variant 产生白灰混杂层级。
- 同一业务弹窗的查看态与编辑态必须切换结构层级,而不是只切换 disabled:查看态只用一个 `Surface variant="secondary"` 包住完整只读字段组,字段本身保持无框;编辑态移除该组 Surface,让所有 `secondary` 控件直接位于 Overlay。只读或锁定字段在编辑态保持无框文字,不得渲染成禁用灰色输入框;弹窗中的独立 DataGrid 使用官方 `secondary` variant。
- 列表页标题只由 HeroUI Pro Navbar 承担;内容区不重复渲染同名二级标题,搜索栏和业务工具栏直接位于页面内容起始位置。
- 全局 `DataTable` 必须向 HeroUI Pro `DataGrid` 传递 populated/loading/empty 状态类;populated 状态的 root、scroll、resizable、content 链使用 `min-height: 0` 并按真实表格高度收缩,不能保留空状态的最小高度。
- DataGrid 普通字段单行截断,详情关联表的长文本最多两行;完整值通过共享 HeroUI `Tooltip` 提供。页面不得使用 `whitespace-normal`、`font-mono` 或原生 `title` 来替代表格文本契约。
- DataGrid 表头、普通字段、主字段、次字段和 Chip 统一使用 `--rc-data-grid-*` Token 与 `pm-data-grid-*` 共享排版类,页面不得单独声明表格字重或颜色。
- DataGrid 的最小列宽总和超过 viewport 时,只允许共享 DataGrid 内部横向滚动;页面和 Modal 不得被宽表撑出。宽度链必须保持 `width: 100%` 与实际内容 `min-width: 100%`。
## 验收补充
- 批量动作只通过 `@heroui-pro/react/action-bar` 的浮动 ActionBar 呈现;普通工具栏保留导入、全量导出和新建,主操作在最右侧。
- 共享 DataGrid 以真实列最小可读宽度之和与当前 viewport 比较决定内部滚动和固定列,不以列数阈值决定宽表。
- 应用切换遵循问题整改基准:移动端一列、`sm` 起固定两列,卡片列表与加载骨架均使用 12px 间距;桌面不扩展为三列。
## 布局约定
- Shell 横向边距:桌面 48px、平板 32px、移动 16px。
- 一级模块间距 32px,普通模块 24px,紧密相关内容 16px。
- Sidebar 使用 Pro `collapsible="offcanvas"`,折叠后宽度为零。
- 登录页使用居中认证结构,桌面卡片约 28rem,字段组间真实间距 24px。
- Umi 开发 watcher 必须忽略 `.playwright-cli`、`docs`、`dist`、`artifacts` 与 `node_modules` 运行产物,避免验收日志触发 `umi.css` HMR 循环和 Inter 字体反复卸载/回退。
This diff is collapsed.
This diff is collapsed.
...@@ -9,19 +9,45 @@ ...@@ -9,19 +9,45 @@
"start": "npm run dev" "start": "npm run dev"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.10", "@ant-design/icons": "^6.3.2",
"@heroicons/react": "^2.2.0", "@fontsource/inter": "^5.3.0",
"@heroui-pro/react": "1.0.0-beta.8",
"@heroui/react": "^3.2.4",
"@heroui/styles": "^3.2.4",
"@internationalized/date": "^3.12.3",
"@number-flow/react": "^0.6.2",
"@react-aria/utils": "^3.34.1",
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
"@tiptap/core": "^3.30.2",
"@tiptap/extension-link": "^3.30.2",
"@tiptap/extension-underline": "^3.30.2",
"@tiptap/extensions": "^3.30.2",
"@tiptap/pm": "^3.30.2",
"@tiptap/react": "^3.30.2",
"@tiptap/starter-kit": "^3.30.2",
"@tiptap/suggestion": "^3.30.2",
"@umijs/max": "^4.5.3", "@umijs/max": "^4.5.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.0.0", "embla-carousel": "^8.6.0",
"echarts-for-react": "^3.0.6", "embla-carousel-react": "^8.6.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"maplibre-gl": "^6.4.1",
"marked": "^18.0.10",
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^18.3.1", "react": "^19.2.8",
"react-dom": "^18.3.1", "react-aria-components": "^1.20.0",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.4.3",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
"umi-request": "^1.4.0", "umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0", "umi-request-progress": "^1.1.0",
......
This diff is collapsed.
...@@ -3,3 +3,5 @@ allowBuilds: ...@@ -3,3 +3,5 @@ allowBuilds:
core-js-pure: false core-js-pure: false
es5-ext: false es5-ext: false
esbuild: false esbuild: false
"@heroui-pro/react": true
"@zowe/secrets-for-zowe-sdk": true
import '@/global.less'; import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { APPLICATION_DISPLAY_NAME, THEME_STORAGE_KEY } from '@/config/application';
if (typeof document !== 'undefined') {
const theme = window.localStorage?.getItem?.(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}
export async function getInitialState() { export async function getInitialState() {
return { return {
name: 'integrity_risk_prevention', name: APPLICATION_DISPLAY_NAME,
}; };
} }
......
# Changelog
## 2025-11-18
- Fix sidebar indicator animation when page is scrolled ([#1778](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1778))
## 2025-08-13
- Use `motion/react` instead of `framer-motion`
- Fix inconsistent `Input` error border color ([#1710](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1710))
## 2025-07-29
- Update to React 19 and Next.js 15.4
## 2025-06-05
- Update `DropdownLabel` to extend a plain `<div>` instead of the `Headless.Label` ([#1699](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1699))
## 2025-04-28
- Update template to Tailwind CSS v4.1.4
## 2025-04-22
- Fix focus styles in the `Avatar`, `Badge`, `Button`, `Checkbox`, and `Switch` components ([#1693](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1693))
## 2025-04-17
- Update `@headlessui/react` dependency to `v2.2.2` to fix listbox performance issues ([#1667](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1667))
## 2025-04-04
- Add new `Combobox` component
- Add new `AuthLayout` component, with example login page, registration page, and forgot password page
- Fix centering of checkboxes, radios, and switches in fields with multi-line labels
- Update template to Tailwind CSS v4.1.3
- Update template to Headless UI v2.2.1 (required for new `Combobox` component)
## 2025-03-24
- Adjust avatar ring opacity from 20% to 10%
## 2025-03-22
- Update template to Tailwind CSS v4.0.15
## 2025-02-22
- Fix border radius of avatars in navbar items
## 2025-02-12
- Fix baseline alignment for buttons with icons ([#1668](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1668))
## 2025-02-10
- Update template to Tailwind CSS v4.0.6
## 2025-01-29
- Fix conflicting outline utilities ([#1661](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1661))
## 2025-01-23
- Update template to Tailwind CSS v4.0
## 2024-10-31
- Fix `disabled` prop in `DropdownItem` component ([#1640](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1640))
- Update types in `SidebarItem` component
## 2024-06-09
- Omit `as` prop from types for Headless UI-based components
## 2024-06-26
- Update `@headlessui/react` dependency to `v2.1.1`
- Update `Dialog`, `Alert`, and `Listbox` to new data-attribute-based transition API
## 2024-06-21
- Update `@headlessui/react` dependency to `v2.1.0`
- Use `DialogBackdrop` in `Alert` and `Dialog` components
- Update to new data-attribute-based transition API
## 2024-06-19
- Prevent content from overflowing the mobile sidebar
## 2024-06-18
- Use consistent `let` and `const` declarations
- Use consistent `clsx` import
- Use consistent `forwardRef` instead of `React.forwardRef` import
- Update `tailwindcss`, `prettier` and `prettier-plugin-tailwindcss` dependencies in Next.js demo app
- Fix class order in `Dropdown` component
## 2024-06-10
- Add `dark:[color-scheme:dark]` class to `Input` component ([#1596](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1596))
## 2024-05-31
- Add Next.js demo app ([#1580](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1580))
- Fix `Avatar` sizing and padding ([#1588](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1588))
## 2024-05-27
- Add `pointer-events-none` to `InputGroup` icon ([#1594](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1594))
- Add default text color to `Table` component ([#1581](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1581))
- Fix TypeScript issues when using `as={Link}` ([#1582](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1582))
- Fix `SidebarItem` import ([#1582](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1582))
- Add `isolate` class to `InputGroup`, `SidebarLayout`, and `StackedLayout` components
- Fix comment indentation and use proper DocBlocks
## 2024-05-24
- Add new `SidebarLayout` component
- Add new `StackedLayout` component
- Add new `Navbar` component
- Add new `Sidebar` component
- Add new `DescriptionList` component
- Add new `Heading` component
- Add new `Divider` component
- Add new `framer-motion` dependency
- Update `@headlessui/react` dependency to `v2.0`
- Remove `as={Fragment}` from the transition components
- Improve and fix a number of types
- Made formatting of `className` and `{...prop}` more consistent across all components
- Made formatting of `forwardRef` consistent across all components
- Update `PaginationGap` component to use a `span` instead of a `div`
- Update the `SidebarHeading` to render use an `h3` instead of a `d`iv
- Remove `children` prop from `Switch` component
## 2024-01-08
- Add `resizable` prop to `Textarea` component ([#1543](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1543))
## 2024-01-03
- Change top-level `let` declarations to `const`
- Fix `--btn-hover-overlay` color for dark/white button ([#1538](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1538))
- Add `forwardRef` to `Input`, `Select`, and `Textarea` components ([#1540](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1540))
## 2023-12-20
- Initial release
This diff is collapsed.
# Catalyst UI Kit
Catalyst is a modern application UI kit built with [Tailwind CSS](https://tailwindcss.com) and [React](https://react.dev/), designed and built by the Tailwind CSS team and included as part of [Tailwind Plus](https://tailwindcss.com/plus).
## Getting started
To get started, first copy the component files included in the downloaded ZIP file into wherever you keep components in your own project. The components are provided in both TypeScript and plain JavaScript, pick whichever set you prefer.
Next, install the dependencies used by the components in Catalyst:
```sh
npm install @headlessui/react motion clsx
```
Catalyst is also designed for the latest version of Tailwind CSS, which is currently Tailwind CSS v4.0. To make sure that you are on the latest version of Tailwind, update it via npm:
```sh
npm install tailwindcss@latest
```
Now you're ready to start using the components in your project — just import them from wherever you're keeping your components and start using them like any of your other React components:
```jsx
import { Input } from './components/input'
import { Field, FieldGroup, Label } from './components/fieldset'
import { Button } from './components/button'
export default function SettingsForm() {
return (
<form>
<FieldGroup>
<Field>
<Label>Name</Label>
<Input name="name" />
</Field>
<Field>
<Label>Email</Label>
<Input type="email" name="email" />
</Field>
<Button type="submit">Save changes</Button>
</FieldGroup>
</form>
)
}
```
Additional installation instructions can be found in the Catalyst documentation.
## Documentation
You can find the Catalyst documentation at https://catalyst.tailwindui.com/docs.
## License
This site template is a commercial product and is licensed under the [Tailwind Plus license](https://tailwindcss.com/plus/license).
## Learn more
To learn more about the technologies used in this site template, see the following resources:
- [Tailwind CSS](https://tailwindcss.com/docs) - the official Tailwind CSS documentation
- [Headless UI](https://headlessui.dev) - the official Headless UI documentation
- [React](https://react.dev) - the official React documentation
- [Framer Motion](https://www.framer.com/docs/) - the official Framer Motion documentation
- [clsx](https://github.com/lukeed/clsx) - the GitHub repo for the `clsx` helper
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { Text } from './text'
const sizes = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
export function Alert({ size = 'md', className, children, ...props }) {
return (
<Headless.Dialog {...props}>
<Headless.DialogBackdrop
transition
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/15 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16"
/>
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
<div className="grid min-h-full grid-rows-[1fr_auto_1fr] justify-items-center p-8 sm:grid-rows-[1fr_auto_3fr] sm:p-4">
<Headless.DialogPanel
transition
className={clsx(
className,
sizes[size],
'row-start-2 w-full rounded-2xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:rounded-2xl sm:p-6 forced-colors:outline',
'transition duration-100 will-change-transform data-closed:opacity-0 data-enter:ease-out data-closed:data-enter:scale-95 data-leave:ease-in'
)}
>
{children}
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
)
}
export function AlertTitle({ className, ...props }) {
return (
<Headless.DialogTitle
{...props}
className={clsx(
className,
'text-center text-base/6 font-semibold text-balance text-zinc-950 sm:text-left sm:text-sm/6 sm:text-wrap'
)}
/>
)
}
export function AlertDescription({ className, ...props }) {
return (
<Headless.Description
as={Text}
{...props}
className={clsx(className, 'mt-2 text-center text-pretty sm:text-left')}
/>
)
}
export function AlertBody({ className, ...props }) {
return <div {...props} className={clsx(className, 'mt-4')} />
}
export function AlertActions({ className, ...props }) {
return (
<div
{...props}
className={clsx(
className,
'mt-6 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:mt-4 sm:flex-row sm:*:w-auto'
)}
/>
)
}
export function AuthLayout({ children }) {
return (
<main className="flex min-h-dvh flex-col p-2">
<div className="flex grow items-center justify-center p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5">
{children}
</div>
</main>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { TouchTarget } from './button'
import { Link } from './link'
export function Avatar({ src = null, square = false, initials, alt = '', className, ...props }) {
return (
<span
data-slot="avatar"
{...props}
className={clsx(
className,
// Basic layout
'inline-grid shrink-0 align-middle [--avatar-radius:20%] *:col-start-1 *:row-start-1',
'outline -outline-offset-1 outline-black/10',
// Border radius
square ? 'rounded-(--avatar-radius) *:rounded-(--avatar-radius)' : 'rounded-full *:rounded-full'
)}
>
{initials && (
<svg
className="size-full fill-current p-[5%] text-[48px] font-medium uppercase select-none"
viewBox="0 0 100 100"
aria-hidden={alt ? undefined : 'true'}
>
{alt && <title>{alt}</title>}
<text x="50%" y="50%" alignmentBaseline="middle" dominantBaseline="middle" textAnchor="middle" dy=".125em">
{initials}
</text>
</svg>
)}
{src && <img className="size-full" src={src} alt={alt} />}
</span>
)
}
export const AvatarButton = forwardRef(function AvatarButton(
{ src, square = false, initials, alt, className, ...props },
ref
) {
let classes = clsx(
className,
square ? 'rounded-[20%]' : 'rounded-full',
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
)
return typeof props.href === 'string' ? (
<Link {...props} className={classes} ref={ref}>
<TouchTarget>
<Avatar src={src} square={square} initials={initials} alt={alt} />
</TouchTarget>
</Link>
) : (
<Headless.Button {...props} className={classes} ref={ref}>
<TouchTarget>
<Avatar src={src} square={square} initials={initials} alt={alt} />
</TouchTarget>
</Headless.Button>
)
})
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { TouchTarget } from './button'
import { Link } from './link'
const colors = {
red: 'bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25',
orange:
'bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25',
amber:
'bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30',
yellow:
'bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30',
lime: 'bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30',
green:
'bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25',
emerald:
'bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25',
teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25',
cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30',
sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25',
blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25',
indigo:
'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25',
violet:
'bg-violet-500/15 text-violet-700 group-data-hover:bg-violet-500/25',
purple:
'bg-purple-500/15 text-purple-700 group-data-hover:bg-purple-500/25',
fuchsia:
'bg-fuchsia-400/15 text-fuchsia-700 group-data-hover:bg-fuchsia-400/25',
pink: 'bg-pink-400/15 text-pink-700 group-data-hover:bg-pink-400/25',
rose: 'bg-rose-400/15 text-rose-700 group-data-hover:bg-rose-400/25',
zinc: 'bg-zinc-600/10 text-zinc-700 group-data-hover:bg-zinc-600/20',
}
export function Badge({ color = 'zinc', className, ...props }) {
return (
<span
{...props}
className={clsx(
className,
'inline-flex items-center gap-x-1.5 rounded-md px-1.5 py-0.5 text-sm/5 font-medium sm:text-xs/5 forced-colors:outline',
colors[color]
)}
/>
)
}
export const BadgeButton = forwardRef(function BadgeButton(
{ color = 'zinc', className, children, ...props },
ref
) {
let classes = clsx(
className,
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
)
return typeof props.href === 'string' ? (
<Link {...props} className={classes} ref={ref}>
<TouchTarget>
<Badge color={color}>{children}</Badge>
</TouchTarget>
</Link>
) : (
<Headless.Button {...props} className={classes} ref={ref}>
<TouchTarget>
<Badge color={color}>{children}</Badge>
</TouchTarget>
</Headless.Button>
)
})
This diff is collapsed.
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
export function CheckboxGroup({ className, ...props }) {
return (
<div
data-slot="control"
{...props}
className={clsx(
className,
// Basic groups
'space-y-3',
// With descriptions
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
export function CheckboxField({ className, ...props }) {
return (
<Headless.Field
data-slot="field"
{...props}
className={clsx(
className,
// Base layout
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
// Control layout
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
// Label layout
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
// Description layout
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
// With description
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
const base = [
// Basic layout
'relative isolate flex size-4.5 items-center justify-center rounded-[0.3125rem] sm:size-4',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm',
// Background color when checked
'group-data-checked:before:bg-(--checkbox-checked-bg)',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Background color applied to control in dark mode
,
// Border
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)',
,
// Inner highlight shadow
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
,
// Focus ring
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
// Disabled state
'group-data-disabled:opacity-50',
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
,
// Forced colors mode
'forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
,
]
const colors = {
'dark/zinc': [
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
,
],
'dark/white': [
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
,
],
white:
'[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15',
dark: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
zinc: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90',
red: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90',
orange:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90',
amber:
'[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80',
yellow:
'[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80',
lime: '[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80',
green:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90',
emerald:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90',
teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90',
indigo:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
violet:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90',
purple:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90',
fuchsia:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90',
pink: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90',
rose: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90',
}
export function Checkbox({ color = 'dark/zinc', className, ...props }) {
return (
<Headless.Checkbox
data-slot="control"
{...props}
className={clsx(className, 'group inline-flex focus:outline-hidden')}
>
<span className={clsx([base, colors[color]])}>
<svg
className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
viewBox="0 0 14 14"
fill="none"
>
{/* Checkmark icon */}
<path
className="opacity-100 group-data-indeterminate:opacity-0"
d="M3 8L6 11L11 3.5"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Indeterminate icon */}
<path
className="opacity-0 group-data-indeterminate:opacity-100"
d="M3 7H11"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Headless.Checkbox>
)
}
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { useState } from 'react'
export function Combobox({
options,
displayValue,
filter,
anchor = 'bottom',
className,
placeholder,
autoFocus,
'aria-label': ariaLabel,
children,
...props
}) {
const [query, setQuery] = useState('')
const filteredOptions =
query === ''
? options
: options.filter((option) =>
filter ? filter(option, query) : displayValue(option)?.toLowerCase().includes(query.toLowerCase())
)
return (
<Headless.Combobox {...props} multiple={false} virtual={{ options: filteredOptions }} onClose={() => setQuery('')}>
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
// Invalid state
'has-data-invalid:before:shadow-red-500/10',
])}
>
<Headless.ComboboxInput
autoFocus={autoFocus}
data-slot="control"
aria-label={ariaLabel}
displayValue={(option) => displayValue(option) ?? ''}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
className={clsx([
className,
// Basic layout
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Horizontal padding
'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20',
// Background color
'bg-transparent',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
// Disabled state
'data-disabled:border-zinc-950/20',
// System icons
,
])}
/>
<Headless.ComboboxButton className="group absolute inset-y-0 right-0 flex items-center px-2">
<svg
className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 group-data-hover:stroke-zinc-700 sm:size-4 forced-colors:stroke-[CanvasText]"
viewBox="0 0 16 16"
aria-hidden="true"
fill="none"
>
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Headless.ComboboxButton>
</span>
<Headless.ComboboxOptions
transition
anchor={anchor}
className={clsx(
// Anchor positioning
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
// Base styles,
'isolate min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible',
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
'outline outline-transparent focus:outline-hidden',
// Handle scrolling when menu won't fit in viewport
'overflow-y-scroll overscroll-contain',
// Popover background
'bg-white/75 backdrop-blur-xl',
// Shadows
'shadow-lg ring-1 ring-zinc-950/10',
// Transitions
'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
)}
>
{({ option }) => children(option)}
</Headless.ComboboxOptions>
</Headless.Combobox>
)
}
export function ComboboxOption({ children, className, ...props }) {
let sharedClasses = clsx(
// Base
'flex min-w-0 items-center',
// Icons
'*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
'*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white',
'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
// Avatars
'*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
)
return (
<Headless.ComboboxOption
{...props}
className={clsx(
// Basic layout
'group/option grid w-full cursor-default grid-cols-[1fr_--spacing(5)] items-baseline gap-x-2 rounded-lg py-2.5 pr-2 pl-3.5 sm:grid-cols-[1fr_--spacing(4)] sm:py-1.5 sm:pr-2 sm:pl-3',
// Typography
'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus
'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
// Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
// Disabled
'data-disabled:opacity-50'
)}
>
<span className={clsx(className, sharedClasses)}>{children}</span>
<svg
className="relative col-start-2 hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Headless.ComboboxOption>
)
}
export function ComboboxLabel({ className, ...props }) {
return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
}
export function ComboboxDescription({ className, children, ...props }) {
return (
<span
{...props}
className={clsx(
className,
'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink'
)}
>
<span className="flex-1 truncate">{children}</span>
</span>
)
}
import clsx from 'clsx'
export function DescriptionList({ className, ...props }) {
return (
<dl
{...props}
className={clsx(
className,
'grid grid-cols-1 text-base/6 sm:grid-cols-[min(50%,--spacing(80))_auto] sm:text-sm/6'
)}
/>
)
}
export function DescriptionTerm({ className, ...props }) {
return (
<dt
{...props}
className={clsx(
className,
'col-start-1 border-t border-zinc-950/5 pt-3 text-zinc-500 first:border-none sm:border-t sm:border-zinc-950/5 sm:py-3 sm:dark:border-white/5'
)}
/>
)
}
export function DescriptionDetails({ className, ...props }) {
return (
<dd
{...props}
className={clsx(
className,
'pt-1 pb-3 text-zinc-950 sm:border-t sm:border-zinc-950/5 sm:py-3 sm:nth-2:border-none'
)}
/>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { Text } from './text'
const sizes = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
export function Dialog({ size = 'lg', className, children, ...props }) {
return (
<Headless.Dialog {...props}>
<Headless.DialogBackdrop
transition
className="fixed inset-0 z-50 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16"
/>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto pt-6 sm:pt-0">
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
<Headless.DialogPanel
transition
className={clsx(
className,
sizes[size],
'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl forced-colors:outline',
'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
)}
>
{children}
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
)
}
export function DialogTitle({ className, ...props }) {
return (
<Headless.DialogTitle
{...props}
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6')}
/>
)
}
export function DialogDescription({ className, ...props }) {
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
}
export function DialogBody({ className, ...props }) {
return <div {...props} className={clsx(className, 'mt-6')} />
}
export function DialogActions({ className, ...props }) {
return (
<div
{...props}
className={clsx(
className,
'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
)}
/>
)
}
import clsx from 'clsx'
export function Divider({ soft = false, className, ...props }) {
return (
<hr
role="presentation"
{...props}
className={clsx(
className,
'w-full border-t',
soft && 'border-zinc-950/5',
!soft && 'border-zinc-950/10'
)}
/>
)
}
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { Button } from './button'
import { Link } from './link'
export function Dropdown(props) {
return <Headless.Menu {...props} />
}
export function DropdownButton({ as = Button, ...props }) {
return <Headless.MenuButton as={as} {...props} />
}
export function DropdownMenu({ anchor = 'bottom', className, ...props }) {
return (
<Headless.MenuItems
{...props}
transition
anchor={anchor}
className={clsx(
className,
// Anchor positioning
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(1)] data-[anchor~=end]:[--anchor-offset:6px] data-[anchor~=start]:[--anchor-offset:-6px] sm:data-[anchor~=end]:[--anchor-offset:4px] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
// Base styles
'isolate w-max rounded-xl p-1',
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
'outline outline-transparent focus:outline-hidden',
// Handle scrolling when menu won't fit in viewport
'overflow-y-auto',
// Popover background
'bg-white/75 backdrop-blur-xl',
// Shadows
'shadow-lg ring-1 ring-zinc-950/10',
// Define grid at the menu level if subgrid is supported
'supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]',
// Transitions
'transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0'
)}
/>
)
}
export function DropdownItem({ className, ...props }) {
let classes = clsx(
className,
// Base styles
'group cursor-default rounded-lg px-3.5 py-2.5 focus:outline-hidden sm:px-3 sm:py-1.5',
// Text styles
'text-left text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus
'data-focus:bg-blue-500 data-focus:text-white',
// Disabled state
'data-disabled:opacity-50',
// Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText] forced-colors:data-focus:*:data-[slot=icon]:text-[HighlightText]',
// Use subgrid when available but fallback to an explicit grid layout if not
'col-span-full grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] items-center supports-[grid-template-columns:subgrid]:grid-cols-subgrid',
// Icons
'*:data-[slot=icon]:col-start-1 *:data-[slot=icon]:row-start-1 *:data-[slot=icon]:mr-2.5 *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:mr-2 sm:*:data-[slot=icon]:size-4',
'*:data-[slot=icon]:text-zinc-500 data-focus:*:data-[slot=icon]:text-white',
// Avatar
'*:data-[slot=avatar]:mr-2.5 *:data-[slot=avatar]:-ml-1 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:mr-2 sm:*:data-[slot=avatar]:size-5'
)
return typeof props.href === 'string' ? (
<Headless.MenuItem as={Link} {...props} className={classes} />
) : (
<Headless.MenuItem as="button" type="button" {...props} className={classes} />
)
}
export function DropdownHeader({ className, ...props }) {
return <div {...props} className={clsx(className, 'col-span-5 px-3.5 pt-2.5 pb-1 sm:px-3')} />
}
export function DropdownSection({ className, ...props }) {
return (
<Headless.MenuSection
{...props}
className={clsx(
className,
// Define grid at the section level instead of the item level if subgrid is supported
'col-span-full supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]'
)}
/>
)
}
export function DropdownHeading({ className, ...props }) {
return (
<Headless.MenuHeading
{...props}
className={clsx(
className,
'col-span-full grid grid-cols-[1fr_auto] gap-x-12 px-3.5 pt-2 pb-1 text-sm/5 font-medium text-zinc-500 sm:px-3 sm:text-xs/5'
)}
/>
)
}
export function DropdownDivider({ className, ...props }) {
return (
<Headless.MenuSeparator
{...props}
className={clsx(
className,
'col-span-full mx-3.5 my-1 h-px border-0 bg-zinc-950/5 sm:mx-3 forced-colors:bg-[CanvasText]'
)}
/>
)
}
export function DropdownLabel({ className, ...props }) {
return <div {...props} data-slot="label" className={clsx(className, 'col-start-2 row-start-1')} {...props} />
}
export function DropdownDescription({ className, ...props }) {
return (
<Headless.Description
data-slot="description"
{...props}
className={clsx(
className,
'col-span-2 col-start-2 row-start-2 text-sm/5 text-zinc-500 group-data-focus:text-white sm:text-xs/5 forced-colors:group-data-focus:text-[HighlightText]'
)}
/>
)
}
export function DropdownShortcut({ keys, className, ...props }) {
return (
<Headless.Description
as="kbd"
{...props}
className={clsx(className, 'col-start-5 row-start-1 flex justify-self-end')}
>
{(Array.isArray(keys) ? keys : keys.split('')).map((char, index) => (
<kbd
key={index}
className={clsx([
'min-w-[2ch] text-center font-sans text-zinc-400 capitalize group-data-focus:text-white forced-colors:group-data-focus:text-[HighlightText]',
// Make sure key names that are longer than one character (like "Tab") have extra space
index > 0 && char.length > 1 && 'pl-1',
])}
>
{char}
</kbd>
))}
</Headless.Description>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
export function Fieldset({ className, ...props }) {
return (
<Headless.Fieldset
{...props}
className={clsx(className, '*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6')}
/>
)
}
export function Legend({ className, ...props }) {
return (
<Headless.Legend
data-slot="legend"
{...props}
className={clsx(
className,
'text-base/6 font-semibold text-zinc-950 data-disabled:opacity-50 sm:text-sm/6'
)}
/>
)
}
export function FieldGroup({ className, ...props }) {
return <div data-slot="control" {...props} className={clsx(className, 'space-y-8')} />
}
export function Field({ className, ...props }) {
return (
<Headless.Field
{...props}
className={clsx(
className,
'[&>[data-slot=label]+[data-slot=control]]:mt-3',
'[&>[data-slot=label]+[data-slot=description]]:mt-1',
'[&>[data-slot=description]+[data-slot=control]]:mt-3',
'[&>[data-slot=control]+[data-slot=description]]:mt-3',
'[&>[data-slot=control]+[data-slot=error]]:mt-3',
'*:data-[slot=label]:font-medium'
)}
/>
)
}
export function Label({ className, ...props }) {
return (
<Headless.Label
data-slot="label"
{...props}
className={clsx(
className,
'text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6'
)}
/>
)
}
export function Description({ className, ...props }) {
return (
<Headless.Description
data-slot="description"
{...props}
className={clsx(className, 'text-base/6 text-zinc-500 data-disabled:opacity-50 sm:text-sm/6')}
/>
)
}
export function ErrorMessage({ className, ...props }) {
return (
<Headless.Description
data-slot="error"
{...props}
className={clsx(className, 'text-base/6 text-red-600 data-disabled:opacity-50 sm:text-sm/6')}
/>
)
}
import clsx from 'clsx'
export function Heading({ className, level = 1, ...props }) {
let Element = `h${level}`
return (
<Element
{...props}
className={clsx(className, 'text-2xl/8 font-semibold text-zinc-950 sm:text-xl/8')}
/>
)
}
export function Subheading({ className, level = 2, ...props }) {
let Element = `h${level}`
return (
<Element
{...props}
className={clsx(className, 'text-base/7 font-semibold text-zinc-950 sm:text-sm/6')}
/>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export function InputGroup({ children }) {
return (
<span
data-slot="control"
className={clsx(
'relative isolate block',
'has-[[data-slot=icon]:first-child]:[&_input]:pl-10 has-[[data-slot=icon]:last-child]:[&_input]:pr-10 sm:has-[[data-slot=icon]:first-child]:[&_input]:pl-8 sm:has-[[data-slot=icon]:last-child]:[&_input]:pr-8',
'*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:absolute *:data-[slot=icon]:top-3 *:data-[slot=icon]:z-10 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:top-2.5 sm:*:data-[slot=icon]:size-4',
'[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5 [&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5',
'*:data-[slot=icon]:text-zinc-500'
)}
>
{children}
</span>
)
}
const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
export const Input = forwardRef(function Input(
{ className, ...props },
ref
) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Input
ref={ref}
{...props}
className={clsx([
// Date classes
props.type &&
dateTypes.includes(props.type) && [
'[&::-webkit-datetime-edit-fields-wrapper]:p-0',
'[&::-webkit-date-and-time-value]:min-h-[1.5em]',
'[&::-webkit-datetime-edit]:inline-flex',
'[&::-webkit-datetime-edit]:p-0',
'[&::-webkit-datetime-edit-year-field]:p-0',
'[&::-webkit-datetime-edit-month-field]:p-0',
'[&::-webkit-datetime-edit-day-field]:p-0',
'[&::-webkit-datetime-edit-hour-field]:p-0',
'[&::-webkit-datetime-edit-minute-field]:p-0',
'[&::-webkit-datetime-edit-second-field]:p-0',
'[&::-webkit-datetime-edit-millisecond-field]:p-0',
'[&::-webkit-datetime-edit-meridiem-field]:p-0',
],
// Basic layout
'relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20',
// Background color
'bg-transparent',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
// Disabled state
'data-disabled:border-zinc-950/20',
// System icons
,
])}
/>
</span>
)
})
/**
* TODO: Update this component to use your client-side framework's link
* component. We've provided examples of how to do this for Next.js, Remix, and
* Inertia.js in the Catalyst documentation:
*
* https://catalyst.tailwindui.com/docs#client-side-router-integration
*/
import * as Headless from '@headlessui/react'
import React, { forwardRef } from 'react'
import { Link as UmiLink } from 'umi'
export const Link = forwardRef(function Link({ href, ...props }, ref) {
return (
<Headless.DataInteractive>
<UmiLink {...props} to={href} ref={ref} />
</Headless.DataInteractive>
)
})
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { Fragment } from 'react'
export function Listbox({ className, placeholder, autoFocus, 'aria-label': ariaLabel, children: options, ...props }) {
return (
<Headless.Listbox {...props} multiple={false}>
<Headless.ListboxButton
autoFocus={autoFocus}
data-slot="control"
aria-label={ariaLabel}
className={clsx([
className,
// Basic layout
'group relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Hide default focus styles
'focus:outline-hidden',
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-blue-500',
// Disabled state
'data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none',
])}
>
<Headless.ListboxSelectedOption
as="span"
options={options}
placeholder={placeholder && <span className="block truncate text-zinc-500">{placeholder}</span>}
className={clsx([
// Basic layout
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Set minimum height for when no value is selected
'min-h-11 sm:min-h-9',
// Horizontal padding
'pr-[calc(--spacing(7)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
// Typography
'text-left text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Border
'border border-zinc-950/10 group-data-active:border-zinc-950/20 group-data-hover:border-zinc-950/20',
// Background color
'bg-transparent',
// Invalid state
'group-data-invalid:border-red-500 group-data-hover:group-data-invalid:border-red-500',
// Disabled state
'group-data-disabled:border-zinc-950/20 group-data-disabled:opacity-100',
])}
/>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 sm:size-4 forced-colors:stroke-[CanvasText]"
viewBox="0 0 16 16"
aria-hidden="true"
fill="none"
>
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</Headless.ListboxButton>
<Headless.ListboxOptions
transition
anchor="selection start"
className={clsx(
// Anchor positioning
'[--anchor-offset:-1.625rem] [--anchor-padding:--spacing(4)] sm:[--anchor-offset:-1.375rem]',
// Base styles
'isolate w-max min-w-[calc(var(--button-width)+1.75rem)] scroll-py-1 rounded-xl p-1 select-none',
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
'outline outline-transparent focus:outline-hidden',
// Handle scrolling when menu won't fit in viewport
'overflow-y-scroll overscroll-contain',
// Popover background
'bg-white/75 backdrop-blur-xl',
// Shadows
'shadow-lg ring-1 ring-zinc-950/10',
// Transitions
'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
)}
>
{options}
</Headless.ListboxOptions>
</Headless.Listbox>
)
}
export function ListboxOption({ children, className, ...props }) {
let sharedClasses = clsx(
// Base
'flex min-w-0 items-center',
// Icons
'*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
'*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white',
'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
// Avatars
'*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
)
return (
<Headless.ListboxOption as={Fragment} {...props}>
{({ selectedOption }) => {
if (selectedOption) {
return <div className={clsx(className, sharedClasses)}>{children}</div>
}
return (
<div
className={clsx(
// Basic layout
'group/option grid cursor-default grid-cols-[--spacing(5)_1fr] items-baseline gap-x-2 rounded-lg py-2.5 pr-3.5 pl-2 sm:grid-cols-[--spacing(4)_1fr] sm:py-1.5 sm:pr-3 sm:pl-1.5',
// Typography
'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus
'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
// Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
// Disabled
'data-disabled:opacity-50'
)}
>
<svg
className="relative hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className={clsx(className, sharedClasses, 'col-start-2')}>{children}</span>
</div>
)
}}
</Headless.ListboxOption>
)
}
export function ListboxLabel({ className, ...props }) {
return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
}
export function ListboxDescription({ className, children, ...props }) {
return (
<span
{...props}
className={clsx(
className,
'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink'
)}
>
<span className="flex-1 truncate">{children}</span>
</span>
)
}
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { LayoutGroup, motion } from 'motion/react'
import React, { forwardRef, useId } from 'react'
import { TouchTarget } from './button'
import { Link } from './link'
export function Navbar({ className, ...props }) {
return <nav {...props} className={clsx(className, 'flex flex-1 items-center gap-4 py-2.5')} />
}
export function NavbarDivider({ className, ...props }) {
return <div aria-hidden="true" {...props} className={clsx(className, 'h-6 w-px bg-zinc-950/10 dark:bg-white/10')} />
}
export function NavbarSection({ className, ...props }) {
let id = useId()
return (
<LayoutGroup id={id}>
<div {...props} className={clsx(className, 'flex items-center gap-3')} />
</LayoutGroup>
)
}
export function NavbarSpacer({ className, ...props }) {
return <div aria-hidden="true" {...props} className={clsx(className, '-ml-4 flex-1')} />
}
export const NavbarItem = forwardRef(function NavbarItem(
{ current, className, children, ...props },
ref
) {
let classes = clsx(
// Base
'relative flex min-w-0 items-center gap-3 rounded-lg p-2 text-left text-base/6 font-medium text-zinc-950 sm:text-sm/5',
// Leading icon/icon-only
'*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
// Trailing icon (down chevron or similar)
'*:not-nth-2:last:data-[slot=icon]:ml-auto *:not-nth-2:last:data-[slot=icon]:size-5 sm:*:not-nth-2:last:data-[slot=icon]:size-4',
// Avatar
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 *:data-[slot=avatar]:[--avatar-radius:var(--radius-md)] sm:*:data-[slot=avatar]:size-6',
// Hover
'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
// Active
'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
// Dark mode
'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white'
)
return (
<span className={clsx(className, 'relative')}>
{current && (
<motion.span
layoutId="current-indicator"
className="absolute inset-x-2 -bottom-2.5 h-0.5 rounded-full bg-zinc-950 dark:bg-white"
/>
)}
{typeof props.href === 'string' ? (
<Link {...props} className={classes} data-current={current ? 'true' : undefined} ref={ref}>
<TouchTarget>{children}</TouchTarget>
</Link>
) : (
<Headless.Button
{...props}
className={clsx('cursor-default', classes)}
data-current={current ? 'true' : undefined}
ref={ref}
>
<TouchTarget>{children}</TouchTarget>
</Headless.Button>
)}
</span>
)
})
export function NavbarLabel({ className, ...props }) {
return <span {...props} className={clsx(className, 'truncate')} />
}
import clsx from 'clsx'
import { Button } from './button'
export function Pagination({ 'aria-label': ariaLabel = 'Page navigation', className, ...props }) {
return <nav aria-label={ariaLabel} {...props} className={clsx(className, 'flex gap-x-2')} />
}
export function PaginationPrevious({ href = null, className, children = 'Previous' }) {
return (
<span className={clsx(className, 'grow basis-0')}>
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label="Previous page">
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2.75 8H13.25M2.75 8L5.25 5.5M2.75 8L5.25 10.5"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{children}
</Button>
</span>
)
}
export function PaginationNext({ href = null, className, children = 'Next' }) {
return (
<span className={clsx(className, 'flex grow basis-0 justify-end')}>
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label="Next page">
{children}
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M13.25 8L2.75 8M13.25 8L10.75 10.5M13.25 8L10.75 5.5"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
</span>
)
}
export function PaginationList({ className, ...props }) {
return <span {...props} className={clsx(className, 'hidden items-baseline gap-x-2 sm:flex')} />
}
export function PaginationPage({ href, className, current = false, children }) {
return (
<Button
href={href}
plain
aria-label={`Page ${children}`}
aria-current={current ? 'page' : undefined}
className={clsx(
className,
'min-w-9 before:absolute before:-inset-px before:rounded-lg',
current && 'before:bg-zinc-950/5'
)}
>
<span className="-mx-0.5">{children}</span>
</Button>
)
}
export function PaginationGap({ className, children = <>&hellip;</>, ...props }) {
return (
<span
aria-hidden="true"
{...props}
className={clsx(className, 'w-9 text-center text-sm/6 font-semibold text-zinc-950 select-none')}
>
{children}
</span>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
export function RadioGroup({ className, ...props }) {
return (
<Headless.RadioGroup
data-slot="control"
{...props}
className={clsx(
className,
// Basic groups
'space-y-3 **:data-[slot=label]:font-normal',
// With descriptions
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
export function RadioField({ className, ...props }) {
return (
<Headless.Field
data-slot="field"
{...props}
className={clsx(
className,
// Base layout
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
// Control layout
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
// Label layout
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
// Description layout
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
// With description
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
const base = [
// Basic layout
'relative isolate flex size-4.75 shrink-0 rounded-full sm:size-4.25',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-0 before:-z-10 before:rounded-full before:bg-white before:shadow-sm',
// Background color when checked
'group-data-checked:before:bg-(--radio-checked-bg)',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Background color applied to control in dark mode
,
// Border
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--radio-checked-border)',
,
// Inner highlight shadow
'after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
,
// Indicator color (light mode)
'[--radio-indicator:transparent] group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:[--radio-indicator:var(--color-zinc-900)]/10',
// Indicator color (dark mode)
,
// Focus ring
'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
// Disabled state
'group-data-disabled:opacity-50',
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--radio-checked-indicator:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
,
]
const colors = {
'dark/zinc': [
'[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
,
],
'dark/white': [
'[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
,
],
white:
'[--radio-checked-bg:var(--color-white)] [--radio-checked-border:var(--color-zinc-950)]/15 [--radio-checked-indicator:var(--color-zinc-900)]',
dark: '[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
zinc: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-zinc-600)] [--radio-checked-border:var(--color-zinc-700)]/90',
red: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-red-600)] [--radio-checked-border:var(--color-red-700)]/90',
orange:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-orange-500)] [--radio-checked-border:var(--color-orange-600)]/90',
amber:
'[--radio-checked-bg:var(--color-amber-400)] [--radio-checked-border:var(--color-amber-500)]/80 [--radio-checked-indicator:var(--color-amber-950)]',
yellow:
'[--radio-checked-bg:var(--color-yellow-300)] [--radio-checked-border:var(--color-yellow-400)]/80 [--radio-checked-indicator:var(--color-yellow-950)]',
lime: '[--radio-checked-bg:var(--color-lime-300)] [--radio-checked-border:var(--color-lime-400)]/80 [--radio-checked-indicator:var(--color-lime-950)]',
green:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-green-600)] [--radio-checked-border:var(--color-green-700)]/90',
emerald:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-emerald-600)] [--radio-checked-border:var(--color-emerald-700)]/90',
teal: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-teal-600)] [--radio-checked-border:var(--color-teal-700)]/90',
cyan: '[--radio-checked-bg:var(--color-cyan-300)] [--radio-checked-border:var(--color-cyan-400)]/80 [--radio-checked-indicator:var(--color-cyan-950)]',
sky: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-sky-500)] [--radio-checked-border:var(--color-sky-600)]/80',
blue: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-blue-600)] [--radio-checked-border:var(--color-blue-700)]/90',
indigo:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-indigo-500)] [--radio-checked-border:var(--color-indigo-600)]/90',
violet:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-violet-500)] [--radio-checked-border:var(--color-violet-600)]/90',
purple:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-purple-500)] [--radio-checked-border:var(--color-purple-600)]/90',
fuchsia:
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-fuchsia-500)] [--radio-checked-border:var(--color-fuchsia-600)]/90',
pink: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-pink-500)] [--radio-checked-border:var(--color-pink-600)]/90',
rose: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-rose-500)] [--radio-checked-border:var(--color-rose-600)]/90',
}
export function Radio({ color = 'dark/zinc', className, ...props }) {
return (
<Headless.Radio
data-slot="control"
{...props}
className={clsx(className, 'group inline-flex focus:outline-hidden')}
>
<span className={clsx([base, colors[color]])}>
<span
className={clsx(
'size-full rounded-full border-[4.5px] border-transparent bg-(--radio-indicator) bg-clip-padding',
// Forced colors mode
'forced-colors:border-[Canvas] forced-colors:group-data-checked:border-[Highlight]'
)}
/>
</span>
</Headless.Radio>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export const Select = forwardRef(function Select({ className, multiple, ...props }, ref) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'group relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-blue-500',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Select
ref={ref}
multiple={multiple}
{...props}
className={clsx([
// Basic layout
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Horizontal padding
multiple
? 'px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)]'
: 'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
// Options (multi-select)
'[&_optgroup]:font-semibold',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20',
// Background color
'bg-transparent',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
// Disabled state
'data-disabled:border-zinc-950/20 data-disabled:opacity-100',
])}
/>
{!multiple && (
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="size-5 stroke-zinc-500 group-has-data-disabled:stroke-zinc-600 sm:size-4 forced-colors:stroke-[CanvasText]"
viewBox="0 0 16 16"
aria-hidden="true"
fill="none"
>
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
)}
</span>
)
})
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { useState } from 'react'
import { NavbarItem } from './navbar'
function OpenMenuIcon() {
return (
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
</svg>
)
}
function CloseMenuIcon() {
return (
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
)
}
function MobileSidebar({ open, close, children }) {
return (
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
/>
<Headless.DialogPanel
transition
className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
onClick={(event) => {
if (event.target instanceof Element && event.target.closest('a[href]')) {
close()
}
}}
>
<div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5">
<div className="-mb-3 px-4 pt-3">
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
<CloseMenuIcon />
</Headless.CloseButton>
</div>
{children}
</div>
</Headless.DialogPanel>
</Headless.Dialog>
)
}
export function SidebarLayout({ navbar, sidebar, children, contentClassName }) {
let [showSidebar, setShowSidebar] = useState(false)
return (
<div className="relative isolate flex min-h-svh w-full bg-white max-lg:flex-col lg:bg-zinc-100">
{/* Sidebar on desktop */}
<div className="fixed inset-y-0 left-0 w-64 max-lg:hidden">{sidebar}</div>
{/* Sidebar on mobile */}
<MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
{sidebar}
</MobileSidebar>
{/* Navbar on mobile */}
<header className="flex items-center px-4 lg:hidden">
<div className="py-2.5">
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
<OpenMenuIcon />
</NavbarItem>
</div>
<div className="min-w-0 flex-1">{navbar}</div>
</header>
{/* Content */}
<main className="flex flex-1 flex-col pb-2 lg:min-w-0 lg:pt-2 lg:pr-2 lg:pl-64">
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5">
<div className={clsx('mx-auto w-full max-w-6xl', contentClassName)}>{children}</div>
</div>
</main>
</div>
)
}
'use client'
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { LayoutGroup, motion } from 'motion/react'
import React, { forwardRef, useId } from 'react'
import { Link as UmiLink } from 'umi'
import { TouchTarget } from './button'
export function Sidebar({ className, ...props }) {
return <nav {...props} className={clsx(className, 'flex h-full min-h-0 flex-col')} />
}
export function SidebarHeader({ className, ...props }) {
return (
<div
{...props}
className={clsx(
className,
'flex flex-col border-b border-zinc-950/5 p-4 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
)}
/>
)
}
export function SidebarBody({ className, ...props }) {
return (
<div
{...props}
className={clsx(
className,
'flex flex-1 flex-col overflow-y-auto p-4 [&>[data-slot=section]+[data-slot=section]]:mt-8'
)}
/>
)
}
export function SidebarFooter({ className, ...props }) {
return (
<div
{...props}
className={clsx(
className,
'flex flex-col border-t border-zinc-950/5 p-4 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
)}
/>
)
}
export function SidebarSection({ className, ...props }) {
let id = useId()
return (
<LayoutGroup id={id}>
<div {...props} data-slot="section" className={clsx(className, 'flex flex-col gap-0.5')} />
</LayoutGroup>
)
}
export function SidebarDivider({ className, ...props }) {
return <hr {...props} className={clsx(className, 'my-4 border-t border-zinc-950/5 lg:-mx-4')} />
}
export function SidebarSpacer({ className, ...props }) {
return <div aria-hidden="true" {...props} className={clsx(className, 'mt-8 flex-1')} />
}
export function SidebarHeading({ className, ...props }) {
return (
<h3 {...props} className={clsx(className, 'mb-1 px-2 text-xs/6 font-medium text-zinc-500 dark:text-zinc-400')} />
)
}
export const SidebarItem = forwardRef(function SidebarItem(
{ current, className, children, ...props },
ref
) {
let href = props.href
let interactiveProps = { ...props }
delete interactiveProps.href
let classes = clsx(
// Base
'flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5',
// Leading icon/icon-only
'*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
// Trailing icon (down chevron or similar)
'*:last:data-[slot=icon]:ml-auto *:last:data-[slot=icon]:size-5 sm:*:last:data-[slot=icon]:size-4',
// Avatar
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 sm:*:data-[slot=avatar]:size-6',
// Hover
'hover:bg-zinc-950/5 hover:*:data-[slot=icon]:fill-zinc-950',
// Active
'active:bg-zinc-950/5 active:*:data-[slot=icon]:fill-zinc-950',
// Current
'data-current:bg-blue-600 data-current:text-white data-current:*:data-[slot=icon]:fill-white',
// Dark mode
'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
'dark:hover:bg-white/5 dark:hover:*:data-[slot=icon]:fill-white',
'dark:active:bg-white/5 dark:active:*:data-[slot=icon]:fill-white',
'dark:data-current:bg-blue-600 dark:data-current:text-white dark:data-current:*:data-[slot=icon]:fill-white'
)
return (
<span className={clsx(className, 'relative')}>
{current && (
<motion.span
layoutId="current-indicator"
className="absolute inset-y-2 -left-4 w-0.5 rounded-full bg-zinc-950 dark:bg-white"
/>
)}
{typeof href === 'string' ? (
<UmiLink
{...interactiveProps}
to={href}
className={classes}
data-current={current ? 'true' : undefined}
ref={ref}
>
<TouchTarget>{children}</TouchTarget>
</UmiLink>
) : (
<Headless.Button
{...props}
className={clsx('cursor-default', classes)}
data-current={current ? 'true' : undefined}
ref={ref}
>
<TouchTarget>{children}</TouchTarget>
</Headless.Button>
)}
</span>
)
})
export function SidebarLabel({ className, ...props }) {
return <span {...props} className={clsx(className, 'truncate')} />
}
'use client'
import * as Headless from '@headlessui/react'
import React, { useState } from 'react'
import { NavbarItem } from './navbar'
function OpenMenuIcon() {
return (
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
</svg>
)
}
function CloseMenuIcon() {
return (
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
)
}
function MobileSidebar({ open, close, children }) {
return (
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
/>
<Headless.DialogPanel
transition
className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
>
<div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5">
<div className="-mb-3 px-4 pt-3">
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
<CloseMenuIcon />
</Headless.CloseButton>
</div>
{children}
</div>
</Headless.DialogPanel>
</Headless.Dialog>
)
}
export function StackedLayout({ navbar, sidebar, children }) {
let [showSidebar, setShowSidebar] = useState(false)
return (
<div className="relative isolate flex min-h-svh w-full flex-col bg-white lg:bg-zinc-100">
{/* Sidebar on mobile */}
<MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
{sidebar}
</MobileSidebar>
{/* Navbar */}
<header className="flex items-center px-4">
<div className="py-2.5 lg:hidden">
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
<OpenMenuIcon />
</NavbarItem>
</div>
<div className="min-w-0 flex-1">{navbar}</div>
</header>
{/* Content */}
<main className="flex flex-1 flex-col pb-2 lg:px-2">
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5">
<div className="mx-auto max-w-6xl">{children}</div>
</div>
</main>
</div>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
export function SwitchGroup({ className, ...props }) {
return (
<div
data-slot="control"
{...props}
className={clsx(
className,
// Basic groups
'space-y-3 **:data-[slot=label]:font-normal',
// With descriptions
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
export function SwitchField({ className, ...props }) {
return (
<Headless.Field
data-slot="field"
{...props}
className={clsx(
className,
// Base layout
'grid grid-cols-[1fr_auto] gap-x-8 gap-y-1 sm:grid-cols-[1fr_auto]',
// Control layout
'*:data-[slot=control]:col-start-2 *:data-[slot=control]:self-start sm:*:data-[slot=control]:mt-0.5',
// Label layout
'*:data-[slot=label]:col-start-1 *:data-[slot=label]:row-start-1',
// Description layout
'*:data-[slot=description]:col-start-1 *:data-[slot=description]:row-start-2',
// With description
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
}
const colors = {
'dark/zinc': [
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)]',
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white]',
],
'dark/white': [
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)]',
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white]',
],
dark: [
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)]',
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white]',
],
zinc: [
'[--switch-bg-ring:var(--color-zinc-700)]/90 [--switch-bg:var(--color-zinc-600)]',
'[--switch-shadow:var(--color-black)]/10 [--switch:white] [--switch-ring:var(--color-zinc-700)]/90',
],
white: [
'[--switch-bg-ring:var(--color-black)]/15 [--switch-bg:white]',
'[--switch-shadow:var(--color-black)]/10 [--switch-ring:transparent] [--switch:var(--color-zinc-950)]',
],
red: [
'[--switch-bg-ring:var(--color-red-700)]/90 [--switch-bg:var(--color-red-600)]',
'[--switch:white] [--switch-ring:var(--color-red-700)]/90 [--switch-shadow:var(--color-red-900)]/20',
],
orange: [
'[--switch-bg-ring:var(--color-orange-600)]/90 [--switch-bg:var(--color-orange-500)]',
'[--switch:white] [--switch-ring:var(--color-orange-600)]/90 [--switch-shadow:var(--color-orange-900)]/20',
],
amber: [
'[--switch-bg-ring:var(--color-amber-500)]/80 [--switch-bg:var(--color-amber-400)]',
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-amber-950)]',
],
yellow: [
'[--switch-bg-ring:var(--color-yellow-400)]/80 [--switch-bg:var(--color-yellow-300)]',
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-yellow-950)]',
],
lime: [
'[--switch-bg-ring:var(--color-lime-400)]/80 [--switch-bg:var(--color-lime-300)]',
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-lime-950)]',
],
green: [
'[--switch-bg-ring:var(--color-green-700)]/90 [--switch-bg:var(--color-green-600)]',
'[--switch:white] [--switch-ring:var(--color-green-700)]/90 [--switch-shadow:var(--color-green-900)]/20',
],
emerald: [
'[--switch-bg-ring:var(--color-emerald-600)]/90 [--switch-bg:var(--color-emerald-500)]',
'[--switch:white] [--switch-ring:var(--color-emerald-600)]/90 [--switch-shadow:var(--color-emerald-900)]/20',
],
teal: [
'[--switch-bg-ring:var(--color-teal-700)]/90 [--switch-bg:var(--color-teal-600)]',
'[--switch:white] [--switch-ring:var(--color-teal-700)]/90 [--switch-shadow:var(--color-teal-900)]/20',
],
cyan: [
'[--switch-bg-ring:var(--color-cyan-400)]/80 [--switch-bg:var(--color-cyan-300)]',
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-cyan-950)]',
],
sky: [
'[--switch-bg-ring:var(--color-sky-600)]/80 [--switch-bg:var(--color-sky-500)]',
'[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20',
],
blue: [
'[--switch-bg-ring:var(--color-blue-700)]/90 [--switch-bg:var(--color-blue-600)]',
'[--switch:white] [--switch-ring:var(--color-blue-700)]/90 [--switch-shadow:var(--color-blue-900)]/20',
],
indigo: [
'[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)]',
'[--switch:white] [--switch-ring:var(--color-indigo-600)]/90 [--switch-shadow:var(--color-indigo-900)]/20',
],
violet: [
'[--switch-bg-ring:var(--color-violet-600)]/90 [--switch-bg:var(--color-violet-500)]',
'[--switch:white] [--switch-ring:var(--color-violet-600)]/90 [--switch-shadow:var(--color-violet-900)]/20',
],
purple: [
'[--switch-bg-ring:var(--color-purple-600)]/90 [--switch-bg:var(--color-purple-500)]',
'[--switch:white] [--switch-ring:var(--color-purple-600)]/90 [--switch-shadow:var(--color-purple-900)]/20',
],
fuchsia: [
'[--switch-bg-ring:var(--color-fuchsia-600)]/90 [--switch-bg:var(--color-fuchsia-500)]',
'[--switch:white] [--switch-ring:var(--color-fuchsia-600)]/90 [--switch-shadow:var(--color-fuchsia-900)]/20',
],
pink: [
'[--switch-bg-ring:var(--color-pink-600)]/90 [--switch-bg:var(--color-pink-500)]',
'[--switch:white] [--switch-ring:var(--color-pink-600)]/90 [--switch-shadow:var(--color-pink-900)]/20',
],
rose: [
'[--switch-bg-ring:var(--color-rose-600)]/90 [--switch-bg:var(--color-rose-500)]',
'[--switch:white] [--switch-ring:var(--color-rose-600)]/90 [--switch-shadow:var(--color-rose-900)]/20',
],
}
export function Switch({ color = 'dark/zinc', className, ...props }) {
return (
<Headless.Switch
data-slot="control"
{...props}
className={clsx(
className,
// Base styles
'group relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8',
// Transitions
'transition duration-0 ease-in-out data-changing:duration-200',
// Outline and background color in forced-colors mode so switch is still visible
'forced-colors:outline forced-colors:[--switch-bg:Highlight]',
// Unchecked
'bg-zinc-200 ring-1 ring-black/5 ring-inset',
// Checked
'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring)',
// Focus
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
// Hover
'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)',
,
// Disabled
'data-disabled:bg-zinc-200 data-disabled:opacity-50 data-disabled:data-checked:bg-zinc-200 data-disabled:data-checked:ring-black/5',
,
// Color specific styles
colors[color]
)}
>
<span
aria-hidden="true"
className={clsx(
// Basic layout
'pointer-events-none relative inline-block size-4.5 rounded-full sm:size-3.5',
// Transition
'translate-x-0 transition duration-200 ease-in-out',
// Invisible border so the switch is still visible in forced-colors mode
'border border-transparent',
// Unchecked
'bg-white shadow-sm ring-1 ring-black/5',
// Checked
'group-data-checked:bg-(--switch) group-data-checked:shadow-(--switch-shadow) group-data-checked:ring-(--switch-ring)',
'group-data-checked:translate-x-4 sm:group-data-checked:translate-x-3',
// Disabled
'group-data-checked:group-data-disabled:bg-white group-data-checked:group-data-disabled:shadow-sm group-data-checked:group-data-disabled:ring-black/5'
)}
/>
</Headless.Switch>
)
}
'use client';
import clsx from 'clsx';
import { createContext, useContext, useState } from 'react';
import { Link } from './link';
const TableContext = createContext({
bleed: false,
dense: false,
grid: false,
striped: false,
});
export function Table({
bleed = false,
dense = false,
grid = false,
striped = false,
scrollable = true,
className,
children,
...props
}) {
return (
<TableContext.Provider value={{ bleed, dense, grid, striped }}>
<div className="flow-root">
<div
{...props}
className={clsx(
className,
'-mx-(--gutter)',
scrollable && 'overflow-x-auto',
'whitespace-nowrap',
)}
>
<div
className={clsx(
'inline-block min-w-full align-middle',
!bleed && 'sm:px-(--gutter)',
)}
>
<table className="min-w-full text-left text-sm/6 text-zinc-950">
{children}
</table>
</div>
</div>
</div>
</TableContext.Provider>
);
}
export function TableHead({ className, ...props }) {
return <thead {...props} className={clsx(className, 'text-zinc-500')} />;
}
export function TableBody(props) {
return <tbody {...props} />;
}
const TableRowContext = createContext({
href: undefined,
target: undefined,
title: undefined,
});
export function TableRow({ href, target, title, className, ...props }) {
let { striped } = useContext(TableContext);
return (
<TableRowContext.Provider value={{ href, target, title }}>
<tr
{...props}
className={clsx(
className,
href &&
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500',
striped && 'even:bg-zinc-950/2.5',
href && striped && 'hover:bg-zinc-950/5',
href && !striped && 'hover:bg-zinc-950/2.5',
)}
/>
</TableRowContext.Provider>
);
}
export function TableHeader({ className, ...props }) {
let { bleed, grid } = useContext(TableContext);
return (
<th
{...props}
className={clsx(
className,
'border-b border-b-zinc-950/10 px-4 py-2 font-medium first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
grid && 'border-l border-l-zinc-950/5 first:border-l-0',
!bleed && 'sm:first:pl-4 sm:last:pr-4',
)}
/>
);
}
export function TableCell({ className, children, ...props }) {
let { bleed, dense, grid, striped } = useContext(TableContext);
let { href, target, title } = useContext(TableRowContext);
let [cellRef, setCellRef] = useState(null);
return (
<td
ref={href ? setCellRef : undefined}
{...props}
className={clsx(
className,
'relative px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
!striped && 'border-b border-zinc-950/5',
grid && 'border-l border-l-zinc-950/5 first:border-l-0',
dense ? 'py-2.5' : 'py-4',
!bleed && 'sm:first:pl-4 sm:last:pr-4',
)}
>
{href && (
<Link
data-row-link
href={href}
target={target}
aria-label={title}
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
className="absolute inset-0 focus:outline-hidden"
/>
)}
{children}
</td>
);
}
import clsx from 'clsx'
import { Link } from './link'
export function Text({ className, ...props }) {
return (
<p
data-slot="text"
{...props}
className={clsx(className, 'text-base/6 text-zinc-500 sm:text-sm/6')}
/>
)
}
export function TextLink({ className, ...props }) {
return (
<Link
{...props}
className={clsx(
className,
'text-zinc-950 underline decoration-zinc-950/50 data-hover:decoration-zinc-950'
)}
/>
)
}
export function Strong({ className, ...props }) {
return <strong {...props} className={clsx(className, 'font-medium text-zinc-950')} />
}
export function Code({ className, ...props }) {
return (
<code
{...props}
className={clsx(
className,
'rounded-sm border border-zinc-950/10 bg-zinc-950/2.5 px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem]'
)}
/>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export const Textarea = forwardRef(function Textarea({ className, resizable = true, ...props }, ref) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Textarea
ref={ref}
{...props}
className={clsx([
// Basic layout
'relative block h-full w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20',
// Background color
'bg-transparent',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
// Disabled state
'disabled:border-zinc-950/20',
// Resizable
resizable ? 'resize-y' : 'resize-none',
])}
/>
</span>
)
})
import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
import DownloadOutlined from '@ant-design/icons/DownloadOutlined';
import DownOutlined from '@ant-design/icons/DownOutlined';
import ClockCircleOutlined from '@ant-design/icons/ClockCircleOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import CopyOutlined from '@ant-design/icons/CopyOutlined';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import DollarOutlined from '@ant-design/icons/DollarOutlined';
import DatabaseOutlined from '@ant-design/icons/DatabaseOutlined';
import EditOutlined from '@ant-design/icons/EditOutlined';
import EyeOutlined from '@ant-design/icons/EyeOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined';
import GroupOutlined from '@ant-design/icons/GroupOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
import ProjectOutlined from '@ant-design/icons/ProjectOutlined';
import ReloadOutlined from '@ant-design/icons/ReloadOutlined';
import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
import UnlockOutlined from '@ant-design/icons/UnlockOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
import FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined';
import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined';
function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style };
}
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function withSize(Icon) {
return function AppIcon({ size, style, ...props }) {
return (
<Icon aria-hidden="true" {...props} style={iconStyle(size, style)} />
);
};
}
export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined);
export const ChevronRight = withSize(RightOutlined);
export const Clock = withSize(ClockCircleOutlined);
export const Close = withSize(CloseOutlined);
export const Copy = withSize(CopyOutlined);
export const Delete = withSize(DeleteOutlined);
export const Dollar = withSize(DollarOutlined);
export const Database = withSize(DatabaseOutlined);
export const Edit = withSize(EditOutlined);
export const Eye = withSize(EyeOutlined);
export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined);
export const Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined);
export function SidebarPanel({ size = 20, style, ...props }) {
return (
<span
aria-hidden="true"
role="img"
{...props}
style={iconStyle(size, style)}
className={['anticon', props.className].filter(Boolean).join(' ')}
>
<svg
viewBox="0 0 16 16"
width="1em"
height="1em"
fill="none"
xmlns="http://www.w3.org/2000/svg"
focusable="false"
aria-hidden="true"
>
<path
clipRule="evenodd"
d="M6 3.5h6A1.5 1.5 0 0 1 13.5 5v6a1.5 1.5 0 0 1-1.5 1.5H6zm-1.5 0H4A1.5 1.5 0 0 0 2.5 5v6A1.5 1.5 0 0 0 4 12.5h.5zM1 5a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
</span>
);
}
export const More = withSize(MoreOutlined);
export const Moon = withSize(MoonOutlined);
export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined);
export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined);
export const Unlock = withSize(UnlockOutlined);
export const Back = withSize(LeftOutlined);
export const FullscreenExit = withSize(FullscreenExitOutlined);
export const Fullscreen = withSize(FullscreenOutlined);
export const ArrowDownTrayIcon = withSize(DownloadOutlined);
export const ArrowPathIcon = withSize(ReloadOutlined);
export const ArrowTrendingUpIcon = withSize(RiseOutlined);
export const BanknotesIcon = withSize(DollarOutlined);
export const CalendarDaysIcon = Calendar;
export const ChartBarSquareIcon = BarChart;
export const ChartPieIcon = withSize(PieChartOutlined);
export const CheckCircleIcon = withSize(CheckCircleOutlined);
export const ChevronDownIcon = ChevronDown;
export const ChevronRightIcon = ChevronRight;
export const ClipboardDocumentListIcon = withSize(OrderedListOutlined);
export const TableCellsIcon = Database;
export const DocumentDuplicateIcon = Copy;
export const ClockIcon = Clock;
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const FolderOpenIcon = Folder;
export const InformationCircleIcon = Info;
export const LockClosedIcon = Lock;
export const LockOpenIcon = Unlock;
export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined);
export const PlusIcon = withSize(PlusOutlined);
export const UserGroupIcon = Team;
import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { THEME_STORAGE_KEY } from '@/config/application';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark'
? 'dark'
: 'light';
} catch {
return 'light';
}
}
export default function AppShell({
children,
open,
onOpenChange,
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The selected theme still applies for this session when storage is unavailable.
}
}, [theme]);
return (
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
onOpenChange={onOpenChange}
>
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
theme={theme}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import { Avatar, Surface } from '@heroui/react';
export default function ApplicationIdentityWell({ initial, logo }) {
return (
<Surface
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]"
variant="tertiary"
>
<Avatar
className="size-full rounded-[var(--rc-radius-control)] bg-transparent"
color="accent"
variant="soft"
>
{logo ? (
<Avatar.Image className="object-contain p-1.5" src={logo} alt="" />
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
</Surface>
);
}
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({ onThemeChange, theme, title }) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger
aria-label={isMobile ? '打开导航' : isOpen ? '收起侧栏' : '展开侧栏'}
className="app-navbar-trigger rc-navbar-trigger"
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div>
</Navbar.Header>
</Navbar>
);
}
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sheet } from '@heroui-pro/react/sheet';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
const username = userInfo?.username || userInfo?.email || '已登录';
return {
displayName,
username,
initials: String(displayName).trim().slice(0, 1).toUpperCase() || 'U',
};
}
function SidebarUserMenu({ userInfo, onLogout }) {
const user = getUserDisplay(userInfo);
return (
<Dropdown>
<Dropdown.Trigger
aria-label="打开用户菜单"
className="app-sidebar-user-trigger rc-sidebar-user-trigger"
>
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? (
<Avatar.Image alt={user.displayName} src={userInfo.avatar} />
) : null}
<Avatar.Fallback>{user.initials}</Avatar.Fallback>
</Avatar>
<span
className="app-sidebar-user-copy rc-sidebar-user-copy"
data-sidebar="label"
>
<span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span>
</span>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu
aria-label="用户菜单"
onAction={(key) => {
if (key === 'logout') onLogout?.();
}}
>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger">
<div className="rc-sidebar-user-menu-item">
<Label>退出登录</Label>
<LogOut size={14} />
</div>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({
menuGroups,
onLogout,
onOpenApplicationSwitcher,
pathname,
theme,
userInfo,
}) {
return (
<>
<Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--light"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--dark"
src={xyLogoWhite}
/>
</div>
<div
className="app-sidebar-brand-main rc-sidebar-brand-main"
data-sidebar="label"
>
<span className="app-sidebar-title rc-sidebar-title">
{APPLICATION_DISPLAY_NAME}
</span>
</div>
<div
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div>
</div>
</Sidebar.Header>
<Sidebar.Content>
{menuGroups.map((group) => (
<Sidebar.Group key={group.title}>
{group.showLabel !== false ? (
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel>
) : null}
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon>
<Icon size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
))}
</Sidebar.Content>
<Sidebar.Footer>
<SidebarUserMenu userInfo={userInfo} onLogout={onLogout} />
</Sidebar.Footer>
</>
);
}
function MobileDashboardSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
if (!isMobile) {
return null;
}
return (
<Sheet isOpen={isMobileOpen} placement={side} onOpenChange={setMobileOpen}>
<Sheet.Backdrop variant="blur">
<Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog
aria-label={`${APPLICATION_DISPLAY_NAME}导航`}
className="sidebar__mobile-dialog"
>
<Sheet.Heading className="sr-only">
{APPLICATION_DISPLAY_NAME}导航
</Sheet.Heading>
<div
className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar"
data-slot="sidebar-mobile"
>
<SidebarContents
{...props}
onOpenApplicationSwitcher={() => {
setMobileOpen(false);
props.onOpenApplicationSwitcher?.();
}}
/>
</div>
</Sheet.Dialog>
</Sheet.Content>
</Sheet.Backdrop>
</Sheet>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} />
</Sidebar>
<MobileDashboardSidebar {...props} />
</>
);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -58,7 +58,7 @@ export default function SiteWrapper() { ...@@ -58,7 +58,7 @@ export default function SiteWrapper() {
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500"> <div className="flex min-h-screen items-center justify-center text-sm text-muted">
加载中... 加载中...
</div> </div>
); );
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment