* fix(tabs): clamp ListContainer chevron scrolling to the scrollable range (#6749)

* chore(deps): bump react-aria dependencies (#6752)

* chore(deps): bump tailwind-variants to v3.3.1 (#6760)

* chore(docs): release note for 3.2.4

* test(react): behavioral testing infra (#6754)

* fix(link): expose data-slot on link root

* fix(react): put style export condition before default

* test(react): behavioral testing infra and suites

* chore(testing): own vitest-browser-react and clarify coverage gates

* chore: remove pinned version

* fix(autocomplete): prevent popover from clipping the last options (#6766)

* chore(docs): update release note

* fix(styles): prevent subpixel border overflow on badge (#6768)

* chore(docs): update release note

* fix(styles): allow backdrop click to dismiss modal with scroll outside (#6770)

* chore(docs): update release note

* fix(tooltip): restore exit animation when swapping between tooltips (#6772)

* chore(docs): update release note

---------

Co-authored-by: Tyler <56234978+runnerboy22@users.noreply.github.com>
Co-authored-by: Tianen Pang <32772271+tianenpang@users.noreply.github.com>
This commit is contained in:
WK
2026-08-07 21:19:12 +08:00
committed by GitHub
parent 82e8db281f
commit bb9c4b5582
183 changed files with 12223 additions and 620 deletions
+3 -16
View File
@@ -10,26 +10,13 @@ runs:
run_install: false
- name: Setup node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
cache: "pnpm"
check-latest: true
cache: pnpm
node-version-file: ".nvmrc"
cache-dependency-path: pnpm-lock.yaml
registry-url: "https://registry.npmjs.org"
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile
+6
View File
@@ -25,4 +25,10 @@ Closes # <!-- Github issue # here -->
<!-- If Yes, please describe the impact and migration path for existing HeroUI users. -->
## ✅ Testing
- [ ] Interactive / a11y contract changes include or update `*.test.tsx` coverage
- [ ] Scenarios cover roles, `data-*` states, keyboard, disabled, and callbacks as applicable
- [ ] `pnpm test` passes locally
## 📝 Additional Information
+27
View File
@@ -48,6 +48,33 @@ jobs:
- name: Run typecheck
run: pnpm typecheck
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout branch
uses: actions/checkout@v6
- name: Install
uses: ./.github/common-actions/install
- name: Install Playwright Chromium
run: pnpm --filter @heroui/testing exec playwright install --with-deps chromium
- name: Browser tests
run: pnpm --filter @heroui/react test:browser
- name: Jsdom tests with coverage
run: pnpm test:coverage
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v7
with:
name: react-coverage
path: packages/react/coverage
if-no-files-found: ignore
continuous-release:
name: Continuous Release
if: github.repository == 'heroui-inc/heroui'
+2
View File
@@ -12,6 +12,8 @@ node_modules
# testing
coverage
__snapshots__
**/__screenshots__/
**/.vitest-attachments/
coverage.json
# next.js
+27 -6
View File
@@ -36,7 +36,7 @@ HeroUI v3 is a modern React UI library built with **Tailwind CSS v4**, organized
│ │ └── src/components/ # Per-component .css files
│ ├── standard/ # Shared ESLint, Prettier, TS configs
│ ├── storybook/ # Storybook configuration
│ └── vitest/ # Shared Vitest configurations
│ └── testing/ # Shared test harness (@heroui/testing)
├── turbo.json
└── pnpm-workspace.yaml
```
@@ -52,12 +52,33 @@ HeroUI v3 is a modern React UI library built with **Tailwind CSS v4**, organized
| Dev (Docs site, port 3000) | `pnpm dev:docs` |
| Lint | `pnpm lint` |
| Typecheck | `pnpm typecheck` |
| Test all | `pnpm test` |
| Test one component | `pnpm test button` |
| Test all (jsdom + browser) | `pnpm test` |
| Test one file (filter) | `pnpm --filter @heroui/react exec vitest run button` |
| Test with coverage | `pnpm test:coverage` (jsdom floors only — not “done”) |
| Test changed files (local) | `pnpm --filter @heroui/react test:changed` (jsdom only; not a gate) |
| Format | `pnpm run format` |
| Bump version | `pnpm version:bump` |
| Scaffold a new component | `cd packages/react && pnpm add:component ComponentName` |
## Behavioral tests (`@heroui/react`)
- Suites live in `packages/react/tests/components/<name>/`:
- `*.test.tsx` — jsdom (~90% of contracts)
- `*.ssr.test.tsx` — Client SSR smoke via `ssrSmoke()` (not RSC)
- `*.browser.test.tsx` — Playwright (overlays + high-risk portals; not every component)
- optional `fixtures.tsx` — shared JSX across layers
- Import harness from `@heroui/testing/helpers` (`render`, `setupUser`, `runAllTimers`, `ssrSmoke`, `User`). Browser suites: `render` from `@heroui/testing/browser` (wraps `vitest-browser-react`; owned by `@heroui/testing`). Prefer `@/` for sources. Pattern testers: `const user = new User(...); user.createTester(...)` — not a top-level export.
- Query: `getByRole` / label / text first; `data-testid` when needed; avoid class-primary queries.
- Assert: roles/names, HeroUI `data-*` hooks, callbacks, focus, light BEM + documented `data-slot` on compound parts — not colors, full class lists, or RAC internals.
- Fake timers: per-suite only; wire `advanceTimers` into `setupUser` + `User`; use `runAllTimers()`.
- Pattern testers for groups / overlays / collections; skip for Button / Checkbox / Switch / TextField.
- Naming: `describe("Component")`; nested concern; `it` as `supports…` / `calls…` / `exposes…` / `renders…`. SSR: `"Component SSR"`; browser: `"Component (browser)"`.
- Intentional skips (no dedicated suite required): internals (`rac`, `icons`), non-exported helpers (`color-input-group`, `date-input-group`), in-progress `calendar-year-picker`, parent-covered parts (`list-box-item`, `menu-item`, `menu-section`, `list-box-section`), Toast SSR (client portal only — covered by jsdom + browser). Public `input-group` has its own suite. SSR and browser are risk-based, not universal.
- Browser setup (once locally): `pnpm --filter @heroui/testing exec playwright install chromium` before `pnpm test`. CI uses `playwright install --with-deps chromium`, then `test:browser` + `test:coverage` (not a single `pnpm test`).
- Commands: `pnpm test` (jsdom + browser, needs Chromium); filter with `pnpm --filter @heroui/react exec vitest run <name>`.
- Coverage (`pnpm test:coverage`): jsdom only; `src/components/**` minus barrels. Thresholds are **CI floors** (statements/lines can pass with thin smoke). Green coverage ≠ sufficient depth — still require role/callback/focus (and browser for high-risk portals).
- `test:changed`: local jsdom-only shortcut (`vitest related --changed`). Does **not** run browser suites; never use it as the merge gate — use `pnpm test` / CI.
## Git Commit Convention
All commits must follow [Conventional Commits](https://www.conventionalcommits.org/) and are validated by Husky + commitlint. Pre-commit also runs `lint-staged`.
@@ -279,13 +300,13 @@ calendar-year-picker
2. **Build order matters**`@heroui/styles` must build before `@heroui/react`. Running `pnpm build` from root handles this via Turbo's `^build` dependency.
3. **Native addons allowlist**The `pnpm.onlyBuiltDependencies` field in root `package.json` allows native compilation for `esbuild`, `@swc/core`, `@parcel/watcher`, etc. If this field is missing, you'll see "Ignored build scripts" warnings.
3. **Native addons allowlist**`onlyBuiltDependencies` in root `pnpm-workspace.yaml` allows native compilation for `esbuild`, `@swc/core`, `@parcel/watcher`, etc. If this list is missing, you'll see "Ignored build scripts" warnings.
4. **No tests yet**`pnpm test` runs but finds no test files. The Vitest config exists at `packages/vitest`.
4. **Behavioral tests**see [Behavioral tests](#behavioral-tests-herouireact) above. Harness lives in `@heroui/testing`; suites in `packages/react/tests/`.
5. **Commit hooks** — Husky runs `lint-staged` on pre-commit and `commitlint` on commit-msg. Non-conforming commits are rejected.
6. **Run checks before committing**`pnpm lint && pnpm typecheck`
6. **Run checks before committing**`pnpm lint && pnpm typecheck && pnpm test`
## Cursor Cloud Specific
+26 -7
View File
@@ -40,11 +40,17 @@ pnpm build --filter=@heroui/react
# Run linting
pnpm lint
# Run tests
# Run tests (turbo → packages with a test script; jsdom + browser)
pnpm test
# Test specific component
pnpm test button
# Filter by file name (e.g. button.test.tsx)
pnpm --filter @heroui/react exec vitest run button
# Coverage (jsdom floors only — not a depth bar)
pnpm test:coverage
# Changed-set (local jsdom only; not a merge gate)
pnpm --filter @heroui/react test:changed
# Run formatting
pnpm run format
@@ -53,6 +59,18 @@ pnpm run format
pnpm typecheck
```
### Behavioral tests (`@heroui/react`)
- Suites: `packages/react/tests/components/<name>/``*.test.tsx` (jsdom), `*.ssr.test.tsx` (Client SSR via `ssrSmoke()`, not RSC), `*.browser.test.tsx` (Playwright for high-risk portals/overlays; not universal), optional `fixtures.tsx`
- Harness: `@heroui/testing/helpers` (`render`, `setupUser`, `runAllTimers`, `ssrSmoke`, `User`); browser `render` from `@heroui/testing/browser`. Sources via `@/`. Pattern testers: `user.createTester(...)` — do not import `createTester` directly
- Query/assert: role/label/text first; HeroUI `data-*` + light BEM + documented `data-slot` on compound parts; no colors, full class lists, or RAC internals
- Timers: fake timers per-suite only; wire `advanceTimers` into `setupUser` + `User`
- Naming: `describe("Component")`; nested concern; `it` as `supports…` / `calls…` / `exposes…` / `renders…`. SSR: `"Component SSR"`; browser: `"Component (browser)"`
- Intentional skips: internals (`rac`, `icons`), non-exported helpers (`color-input-group`, `date-input-group`), in-progress `calendar-year-picker`, parent-covered parts (`list-box-item`, `menu-item`, …), Toast SSR (client portal — jsdom + browser). Public `input-group` has its own suite. SSR/browser are risk-based
- Browser setup (once locally): `playwright install chromium` before `pnpm test`. CI: `--with-deps`, then `test:browser` + `test:coverage`
- Commands: `pnpm test` (jsdom + browser, needs Chromium); filter with `pnpm --filter @heroui/react exec vitest run <name>`
- Coverage: jsdom-only floors — green ≠ depth. `test:changed`: local jsdom shortcut only, not a merge gate
### Package-Specific Commands
- Use `--filter` flag with package name: `pnpm build --filter=@heroui/react`
@@ -101,7 +119,7 @@ git commit -m "ci: add Claude Code GitHub Action workflow"
│ ├── styles/ # CSS styles & variants (@heroui/styles)
│ ├── standard/ # Shared ESLint, Prettier, TypeScript configs
│ ├── storybook/ # Storybook configuration
│ └── vitest/ # Shared Vitest configurations
│ └── testing/ # Shared test harness (@heroui/testing)
├── turbo.json # Turborepo configuration
└── pnpm-workspace.yaml # Workspace definition
```
@@ -520,9 +538,10 @@ This workflow ensures thorough understanding, proper planning, and high-quality
**IMPORTANT**: The compound component should be exported as the default export.
2. **Testing**:
- Run `pnpm test` for all tests
- Run `pnpm test <component>` for specific component
- Ensure all new features have tests
- Follow Behavioral tests conventions above (semantics-first, `setupUser`, `data-*` state hooks)
- Run `pnpm test` for jsdom + browser; filter with `pnpm --filter @heroui/react exec vitest run <name>`
- Place tests under `packages/react/tests/components/<name>/` (`*.test.tsx` / `*.ssr.test.tsx` / `*.browser.test.tsx`, optional `fixtures.tsx`)
- Prefer arrow functions for harness helpers and test fixtures
3. **Documentation**:
- Docs live in `apps/docs/content/`
+19 -22
View File
@@ -80,8 +80,8 @@ https://www.conventionalcommits.org/ or check out the
3. Make and commit your changes following the
[commit convention](https://github.com/heroui-inc/heroui/blob/main/CONTRIBUTING.md#commit-convention).
As you canary, you can run `pnpm build --filter=<module>` and
`pnpm test packages/<module>/<pkg>` e.g. `pnpm build --filter=avatar & pnpm test packages/components/avatar` to make sure everything works as expected.
As you go, you can run `pnpm build --filter=<module>` and
`pnpm --filter @heroui/react exec vitest run <name>` e.g. `pnpm build --filter=@heroui/react && pnpm --filter @heroui/react exec vitest run avatar` to make sure everything works as expected.
> To know more about the `--filter` option, please check the turborepo [docs](https://turborepo.org/docs/core-concepts/filtering).
@@ -111,11 +111,9 @@ We use [Turbo Repo](https://turborepo.org/) for the project management.
## Start the dev babel server of HeroUI core components
pnpm dev
## optional
## optional — Storybook is also started by `pnpm dev`
pnpm sb ## this will start the storybook server for a faster development and testing.
pnpm dev:docs ## this will start the documentation next.js server and it will automatically detect the changes in the components.
pnpm dev:docs ## documentation next.js server; picks up component changes
```
- If you will be working just on the documentation source code / mdx, you can use the following commands to build
@@ -132,7 +130,7 @@ pnpm dev:docs
- You also can use Storybook to test the components and faster development:
```bash
pnpm sb
pnpm dev
```
Remember that these commands must be executed in the root folder of the project.
@@ -151,29 +149,28 @@ git checkout -b fix/something
4. If your code passes all the tests, then push your feature/fix branch:
All commits that fix bugs or add features need a test.
You can run the nest command for component specific tests.
All commits that fix bugs or add features need a behavioral test when they change interactive component contracts.
See Behavioral tests in [`AGENTS.md`](AGENTS.md): query by role/label, use `setupUser()` from `@heroui/testing/helpers`, assert `data-*` state hooks and callbacks — not CSS pixels.
```bash
# Test current code
# One-time local Playwright install (required before browser suites / `pnpm test`)
pnpm --filter @heroui/testing exec playwright install chromium
# Local: jsdom + browser (needs Chromium installed)
pnpm test
# or
# Filter by file name (e.g. button.test.tsx)
pnpm --filter @heroui/react exec vitest run button
npm run test
# Coverage report (jsdom project + CI floors only — not a depth bar)
pnpm test:coverage
# Optional local shortcut: related jsdom tests for changed files (skips browser)
pnpm --filter @heroui/react test:changed
```
```bash
# Test isolated component code
CI (`QA` Test job) installs Chromium with `--with-deps`, then runs `test:browser` and `test:coverage` separately. Do not treat `test:changed` or coverage floors alone as “fully tested.”
pnpm test button
# or
npm run test button
```
5. Be sure the package builds.
@@ -187,7 +184,7 @@ pnpm build
npm run build
```
> Note: ensure that you have at least Node.js 20.16.0 as well as pnpm 9.6.0 or higher installed on your machine to run the scripts
> Note: ensure that you have at least Node.js 22.x as well as pnpm 10.26.2 (see root `packageManager`) installed on your machine to run the scripts
6. Send your pull request:
@@ -141,6 +141,7 @@ Tooltip 使用以下 CSS 类([查看源码样式](https://github.com/heroui-in
| `closeDelay` | `number` | `500` 或 CSS 变量 | 隐藏 Tooltip 前的延迟(毫秒);可通过 `--tooltip-close-delay` CSS 变量全局配置 |
| `trigger` | `"hover" \| "focus"` | `"hover"` | Tooltip 的触发方式 |
| `isDisabled` | `boolean` | `false` | 是否禁用 Tooltip |
| `shouldSkipAnimation` | `boolean` | `false` | 在多个 Tooltip 之间快速切换时,是否跳过进入与退出动画 |
### Tooltip.Content
| Prop | 类型 | 默认值 | 描述 |
@@ -9,6 +9,16 @@ description: HeroUI v3 的所有更新与变更,包含新功能、修复以及
## 最新版本
### v3.2.4
**2026 年 8 月 4 日**
补丁版本:[Autocomplete](/docs/components/autocomplete) 弹层不再裁剪末尾选项,`scroll="outside"` 的 [Modal](/docs/components/modal) 重新支持点击遮罩关闭,[Tabs](/docs/components/tabs) 的箭头滚动会精确停在边缘,`tailwind-variants` 升级到 `3.3.1` 以修复重新渲染后变体修饰符回退到错误类名的问题,React Aria 升级到 `1.20.0``react-aria@3.51.0`),并为 `@heroui/react` 添加了行为测试套件。
[阅读完整发布说明 →](/docs/react/releases/v3-2-4)
---
### v3.2.3
**2026 年 7 月 30 日**
@@ -7,6 +7,7 @@
"---概述---",
"index",
"---版本列表---",
"v3-2-4",
"v3-2-3",
"v3-2-2",
"v3-2-1",
@@ -0,0 +1,77 @@
---
title: v3.2.4
description: 补丁版本:修复 Autocomplete 弹层裁剪末尾选项的问题,恢复外部滚动模式下 Modal 点击遮罩关闭的行为,将 Tabs.ListContainer 的箭头滚动限制在可滚动范围内,将 tailwind-variants 升级到 3.3.1 以修复重新渲染后变体类名回退的问题,将 React Aria 升级到 1.20.0,并为 @heroui/react 添加行为测试套件。
github:
pull: 6758
---
<div className="flex items-center gap-3 mb-6">
<span className="text-sm text-muted">2026 年 8 月 4 日</span>
</div>
补丁版本:[Autocomplete](/docs/components/autocomplete) 弹层不再裁剪末尾选项,`scroll="outside"` 的 [Modal](/docs/components/modal) 重新支持点击遮罩关闭,[Tabs](/docs/components/tabs) 的箭头滚动现在会精确停在边缘,`tailwind-variants` 升级到 `3.3.1` 以修复重新渲染后变体修饰符回退到错误类名的问题,React Aria 升级到 `1.20.0``react-aria@3.51.0`),并为 `@heroui/react` 添加了行为测试套件。
## 安装
升级到最新版本:
<Tabs items={["npm", "pnpm", "yarn", "bun"]}>
<Tab value="npm">
```bash
npm i @heroui/styles@latest @heroui/react@latest
```
</Tab>
<Tab value="pnpm">
```bash
pnpm add @heroui/styles@latest @heroui/react@latest
```
</Tab>
<Tab value="yarn">
```bash
yarn add @heroui/styles@latest @heroui/react@latest
```
</Tab>
<Tab value="bun">
```bash
bun add @heroui/styles@latest @heroui/react@latest
```
</Tab>
</Tabs>
<Callout type="info">
**正在使用 AI 助手?** 对它说「Hey Cursor,把 HeroUI 升级到最新版本」。它会自动对比版本并应用必要变更。了解更多请参阅 [HeroUI MCP 服务器](/docs/ui-for-agents/mcp-server)。
</Callout>
## 错误修复
- **Variants**:修复重新渲染后变体修饰符回退到错误类名的问题——受控的 `Checkbox` 取消选中时不再从 `checkbox--primary` 变为 `checkbox--secondary`secondary 变体的 `Tabs` 也不再回退到 primary 变体 ([#6760](https://github.com/heroui-inc/heroui/pull/6760))。
- **Autocomplete**:将弹层改为 flex 纵向布局,并允许列表收缩到 `320px` 上限以下,使弹层高度小于内容高度时末尾选项仍可访问——最常见于触发器位于视口中部的 `Modal` 场景 ([#6766](https://github.com/heroui-inc/heroui/pull/6766))。
- **Modal**:将 `scroll="outside"` 时的滚动移交给背景遮罩,使点击遮罩仍可关闭弹窗——占满视口高度的容器不再吞掉遮罩点击并将其判定为弹窗内部 ([#6770](https://github.com/heroui-inc/heroui/pull/6770))。
- **Tooltip**:在相邻 Tooltip 之间切换时保留进入与退出动画。React Aria 的预热计时器会抑制被替换 Tooltip 上的 `data-entering` / `data-exiting`,使其在 HeroUI 的 CSS 关键帧执行前就被卸载——如需恢复 React Aria 的即时切换行为,可传入新增的 `shouldSkipAnimation` 属性 ([#6772](https://github.com/heroui-inc/heroui/pull/6772))。
- **Tabs**:将 `Tabs.ListContainer` 的箭头滚动限制在可滚动范围内,靠近边缘时点击会精确停在边缘而不会滚动过头;已经位于边缘时点击不再触发滚动 ([#6749](https://github.com/heroui-inc/heroui/pull/6749))。
- **Badge**:将背景裁剪到 padding box,使其不再溢出 `1px` 边框,从而消除 Firefox 中圆角处出现的细微颜色边缘 ([#6768](https://github.com/heroui-inc/heroui/pull/6768))。
- **Link**:在 link 根元素上暴露 `data-slot="link"` ([#6754](https://github.com/heroui-inc/heroui/pull/6754))。
- **Exports**:调整 `./styles` 的导出条件顺序,将 `style` 置于 `default` 之前,使 `style` 条件可被正确命中 ([#6754](https://github.com/heroui-inc/heroui/pull/6754))。
## 测试
`@heroui/react` 现已包含基于共享 `@heroui/testing` 测试工具的行为测试套件——提供 Vitest 配置、初始化设置以及 `render`、`setupUser`、`ssrSmoke`、定时器和滚动锁定等辅助函数。测试套件位于 `packages/react/tests/components/`,并拆分为两个 Vitest project`react-jsdom` 负责行为测试与客户端 SSR 冒烟测试,`react-browser` 负责 Playwright 检查。CI 中的 QA 任务会运行两者,覆盖率下限仅对 jsdom 生效。
- **行为测试**:新增测试工具包、各组件测试套件、双 Vitest project 以及 QA 任务 ([#6754](https://github.com/heroui-inc/heroui/pull/6754))。
## 依赖
- **React Aria**:升级到 [React Aria 1.20.0](https://react-aria.adobe.com/releases/v1-20-0)——`react-aria-components@1.20.0`、`react-aria@3.51.0`、`@react-types/shared@3.36.1` 以及 `@internationalized/date@3.12.3` ([#6752](https://github.com/heroui-inc/heroui/pull/6752))。
- **tailwind-variants**:将 `@heroui/react` 和 `@heroui/styles` 升级到 `tailwind-variants@3.3.1` ([#6760](https://github.com/heroui-inc/heroui/pull/6760))。
## 链接
- [组件文档](/docs/react/components)
- [GitHub 仓库](https://github.com/heroui-inc/heroui)
- [GitHub PR #6758](https://github.com/heroui-inc/heroui/pull/6758)
## 贡献者
感谢所有为本次发布做出贡献的人!
<PRContributors />
@@ -118,6 +118,7 @@ The component supports animation states:
| `closeDelay` | `number` | `0` | Delay in milliseconds before hiding tooltip |
| `trigger` | `"hover" \| "focus"` | `"hover"` | How the tooltip is triggered |
| `isDisabled` | `boolean` | `false` | Whether the tooltip is disabled |
| `shouldSkipAnimation` | `boolean` | `false` | Whether to skip the enter and exit animations when moving quickly from one tooltip to another |
### Tooltip.Content
@@ -9,6 +9,16 @@ description: All updates and changes to HeroUI v3, including new features, fixes
## Latest Release
### v3.2.4
**August 4, 2026**
Patch release: the [Autocomplete](/docs/components/autocomplete) popover no longer clips its last options, backdrop clicks dismiss a [Modal](/docs/components/modal) with `scroll="outside"` again, [Tabs](/docs/components/tabs) chevron scrolling lands flush on the edges, `tailwind-variants` is upgraded to `3.3.1` to fix variant modifiers falling back to the wrong class after a re-render, React Aria is upgraded to `1.20.0` (`react-aria@3.51.0`), and `@heroui/react` gains a behavioral test suite.
[Read full release notes →](/docs/react/releases/v3-2-4)
---
### v3.2.3
**July 30, 2026**
@@ -7,6 +7,7 @@
"---Overview---",
"index",
"---Releases---",
"v3-2-4",
"v3-2-3",
"v3-2-2",
"v3-2-1",
@@ -0,0 +1,77 @@
---
title: v3.2.4
description: Patch release that stops the Autocomplete popover from clipping its last options, restores backdrop dismissal for modals with outside scrolling, clamps Tabs.ListContainer chevron scrolling to the scrollable range, upgrades tailwind-variants to 3.3.1 to fix variant classes falling back after a re-render, upgrades React Aria to 1.20.0, and adds a behavioral test suite to @heroui/react.
github:
pull: 6758
---
<div className="flex items-center gap-3 mb-6">
<span className="text-sm text-muted">August 4, 2026</span>
</div>
Patch release: the [Autocomplete](/docs/components/autocomplete) popover no longer clips its last options, backdrop clicks dismiss a [Modal](/docs/components/modal) with `scroll="outside"` again, [Tabs](/docs/components/tabs) chevron scrolling lands flush on the edges, `tailwind-variants` is upgraded to `3.3.1` to fix variant modifiers falling back to the wrong class after a re-render, React Aria is upgraded to `1.20.0` (`react-aria@3.51.0`), and `@heroui/react` gains a behavioral test suite.
## Installation
Update to the latest version:
<Tabs items={["npm", "pnpm", "yarn", "bun"]}>
<Tab value="npm">
```bash
npm i @heroui/styles@latest @heroui/react@latest
```
</Tab>
<Tab value="pnpm">
```bash
pnpm add @heroui/styles@latest @heroui/react@latest
```
</Tab>
<Tab value="yarn">
```bash
yarn add @heroui/styles@latest @heroui/react@latest
```
</Tab>
<Tab value="bun">
```bash
bun add @heroui/styles@latest @heroui/react@latest
```
</Tab>
</Tabs>
<Callout type="info">
**Using AI assistants?** Simply prompt "Hey Cursor, update HeroUI to the latest version" and your AI assistant will automatically compare versions and apply the necessary changes. Learn more about the [HeroUI MCP Server](/docs/ui-for-agents/mcp-server).
</Callout>
## Bug Fixes
- **Variants**: Fix variant modifiers falling back to the wrong class after a re-render — a controlled `Checkbox` no longer switches from `checkbox--primary` to `checkbox--secondary` when it is deselected, and secondary `Tabs` no longer revert to the primary variant ([#6760](https://github.com/heroui-inc/heroui/pull/6760)).
- **Autocomplete**: Lay the popover out as a flex column and let the list shrink below its `320px` cap so the last options stay reachable when the popover is shorter than its content — most often when the trigger sits mid-viewport inside a `Modal` ([#6766](https://github.com/heroui-inc/heroui/pull/6766)).
- **Modal**: Move outside scrolling onto the backdrop so a `scroll="outside"` modal stays dismissible — the full-height container no longer absorbs backdrop clicks and classifies them as inside the dialog ([#6770](https://github.com/heroui-inc/heroui/pull/6770)).
- **Tooltip**: Keep the enter and exit animations when moving from one tooltip to an adjacent one. React Aria's warmup timer suppresses `data-entering` / `data-exiting` on the tooltip being replaced, unmounting it before HeroUI's CSS keyframes can run — pass the new `shouldSkipAnimation` prop to opt back into React Aria's instant swap ([#6772](https://github.com/heroui-inc/heroui/pull/6772)).
- **Tabs**: Clamp `Tabs.ListContainer` chevron scrolling to the scrollable range so a press near an edge lands flush on it instead of overshooting, and a press while already at an edge is now a no-op ([#6749](https://github.com/heroui-inc/heroui/pull/6749)).
- **Badge**: Clip the background to the padding box so it no longer bleeds past the `1px` border, which surfaced as a thin colored fringe on the rounded corners in Firefox ([#6768](https://github.com/heroui-inc/heroui/pull/6768)).
- **Link**: Expose `data-slot="link"` on the link root ([#6754](https://github.com/heroui-inc/heroui/pull/6754)).
- **Exports**: Order the `./styles` export conditions with `style` before `default` so the `style` condition is reachable ([#6754](https://github.com/heroui-inc/heroui/pull/6754)).
## Testing
`@heroui/react` now ships a behavioral test suite built on a shared `@heroui/testing` harness — Vitest configs, setup, and helpers (`render`, `setupUser`, `ssrSmoke`, timers, scroll-lock). Suites live in `packages/react/tests/components/` and split across two Vitest projects: `react-jsdom` for behavior and client-SSR smoke tests, and `react-browser` for Playwright checks. A QA CI job runs both, with coverage floors enforced on jsdom only.
- **Behavioral tests**: Add the testing harness, per-component suites, dual Vitest projects, and the QA job ([#6754](https://github.com/heroui-inc/heroui/pull/6754)).
## Dependencies
- **React Aria**: Upgrade to [React Aria 1.20.0](https://react-aria.adobe.com/releases/v1-20-0) — `react-aria-components@1.20.0`, `react-aria@3.51.0`, `@react-types/shared@3.36.1`, and `@internationalized/date@3.12.3` ([#6752](https://github.com/heroui-inc/heroui/pull/6752)).
- **tailwind-variants**: Upgrade `@heroui/react` and `@heroui/styles` to `tailwind-variants@3.3.1` ([#6760](https://github.com/heroui-inc/heroui/pull/6760)).
## Links
- [Component Docs](/docs/react/components)
- [GitHub Repository](https://github.com/heroui-inc/heroui)
- [GitHub PR #6758](https://github.com/heroui-inc/heroui/pull/6758)
## Contributors
Thanks to everyone who contributed to this release!
<PRContributors />
+3 -3
View File
@@ -25,7 +25,7 @@
"@heroui/react": "workspace:*",
"@iconify-json/gravity-ui": "1.2.10",
"@iconify/react": "6.0.2",
"@internationalized/date": "3.12.2",
"@internationalized/date": "3.12.3",
"@lglab/react-qr-code": "1.7.0",
"@react-aria/overlays": "3.32.1",
"@react-stately/data": "3.16.1",
@@ -54,14 +54,14 @@
"nuqs": "2.8.6",
"posthog-node": "5.21.0",
"react": "19.2.6",
"react-aria-components": "1.19.0",
"react-aria-components": "1.20.0",
"react-dom": "19.2.6",
"remark": "15.0.1",
"remark-gfm": "4.0.1",
"remark-mdx": "3.1.1",
"shiki": "3.20.0",
"tailwind-merge": "3.4.0",
"tailwind-variants": "3.3.0",
"tailwind-variants": "3.3.1",
"ts-morph": "27.0.2",
"usehooks-ts": "3.1.1",
"zod": "4.2.1"
+3 -3
View File
@@ -2,9 +2,9 @@
"home": {
"releaseBadges": [
{
"label": "HeroUI React v3.2.3 — 补丁版本",
"href": "/docs/react/releases/v3-2-3",
"date": "2026-07-30"
"label": "HeroUI React v3.2.4 — 补丁版本",
"href": "/docs/react/releases/v3-2-4",
"date": "2026-08-04"
},
{
"label": "HeroUI Native v1.0.8 — 补丁版本",
+3 -3
View File
@@ -2,9 +2,9 @@
"home": {
"releaseBadges": [
{
"label": "HeroUI React v3.2.3 — patch release",
"href": "/docs/react/releases/v3-2-3",
"date": "2026-07-30"
"label": "HeroUI React v3.2.4 — patch release",
"href": "/docs/react/releases/v3-2-4",
"date": "2026-08-04"
},
{
"label": "HeroUI Native v1.0.8 — patch release",
+4 -19
View File
@@ -37,12 +37,14 @@
"typecheck:docs-cn": "dotenv -- turbo typecheck --filter=@heroui/docs-cn",
"typecheck:react": "turbo typecheck --filter=@heroui/react",
"typecheck:storybook": "dotenv -- turbo typecheck --filter=@heroui/storybook",
"test": "turbo test",
"test:coverage": "turbo test:coverage --filter=@heroui/react",
"typegen:docs": "dotenv -- pnpm --filter @heroui/docs typegen",
"typegen:docs-cn": "dotenv -- pnpm --filter @heroui/docs-cn typegen",
"version:bump": "bumpp package.json packages/react/package.json packages/styles/package.json",
"release": "pnpm run build && bumpp package.json packages/react/package.json packages/styles/package.json --all",
"clean": "pnpm clean:cache && pnpm i",
"clean:cache": "find . \\( -name '.turbo' -o -name '.next' -o -name 'dist' -o -name 'node_modules' -o -name '.source' -o -name '.contentlayer' -o -name 'pnpm-lock.yaml' \\) -prune -exec rm -rf '{}' +",
"clean:cache": "find . \\( -name '.turbo' -o -name '.next' -o -name 'dist' -o -name 'node_modules' -o -name '.source' -o -name '.contentlayer' -o -name '.vitest-attachments' -o -name 'coverage' -o -name 'bundle-sizes.json' -o -name 'tsconfig.tsbuildinfo' -o -name 'pnpm-lock.yaml' \\) -prune -exec rm -rf '{}' +",
"upli": "pnpm up --filter \"./**\" -L -i",
"prepare": "husky",
"postinstall": "pnpm --filter @heroui/styles build && pnpm typegen:docs && pnpm typegen:docs-cn"
@@ -100,24 +102,7 @@
"turbo": "2.7.2",
"typescript": "5.9.3",
"typescript-eslint": "8.50.1",
"vitest": "4.0.16"
},
"pnpm": {
"overrides": {
"shiki": "3.20.0",
"ts-morph": "27.0.2"
},
"onlyBuiltDependencies": [
"@parcel/watcher",
"@swc/core",
"core-js",
"esbuild",
"lmdb",
"msgpackr-extract",
"protobufjs",
"sharp",
"unrs-resolver"
]
"vitest": "4.1.10"
},
"engines": {
"node": ">=22.x",
+21 -11
View File
@@ -31,8 +31,8 @@
},
"./package.json": "./package.json",
"./styles": {
"default": "./src/styles.css",
"style": "./src/styles.css"
"style": "./src/styles.css",
"default": "./src/styles.css"
}
},
"repository": {
@@ -50,6 +50,12 @@
"add:component": "node scripts/add-component.mjs",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:jsdom": "vitest run --project react-jsdom",
"test:browser": "vitest run --project react-browser",
"test:coverage": "vitest run --coverage --project react-jsdom",
"test:changed": "vitest related --changed --project react-jsdom",
"prepack": "clean-package && node scripts/update-exports.mjs",
"postpack": "clean-package restore"
},
@@ -57,17 +63,18 @@
"dependencies": {
"@heroui/styles": "workspace:*",
"@radix-ui/react-avatar": "1.1.11",
"@react-types/shared": "3.36.0",
"@react-types/color": "3.2.0",
"@react-stately/utils": "3.12.1",
"@react-types/color": "3.2.0",
"@react-types/shared": "3.36.1",
"input-otp": "1.4.2",
"tailwind-merge": "3.4.0",
"tailwind-variants": "3.3.0"
"tailwind-variants": "3.3.1"
},
"devDependencies": {
"@heroui/standard": "workspace:*",
"@heroui/testing": "workspace:*",
"@iconify/react": "6.0.2",
"@internationalized/date": "3.12.2",
"@internationalized/date": "3.12.3",
"@react-aria/i18n": "3.13.1",
"@react-aria/ssr": "3.10.1",
"@react-aria/utils": "3.34.1",
@@ -76,20 +83,23 @@
"@types/fs-extra": "11.0.4",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.2.0",
"@vitest/coverage-v8": "4.1.10",
"fs-extra": "11.3.3",
"react": "19.2.6",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-aria": "3.51.0",
"react-aria-components": "1.20.0",
"react-dom": "19.2.6",
"tailwindcss": "4.1.18"
"tailwindcss": "4.1.18",
"vitest": "4.1.10"
},
"peerDependencies": {
"@react-aria/i18n": "^3.13.1",
"@react-aria/ssr": "^3.10.1",
"@react-aria/utils": "^3.34.1",
"react": ">=19.0.0",
"react-aria": "^3.50.0",
"react-aria-components": "^1.19.0",
"react-aria": "^3.51.0",
"react-aria-components": "^1.20.0",
"react-dom": ">=19.0.0",
"tailwindcss": ">=4.0.0"
}
+1 -1
View File
@@ -61,8 +61,8 @@ async function generateExports() {
},
"./package.json": "./package.json",
"./styles": {
default: "./dist/styles.css",
style: "./dist/styles.css",
default: "./dist/styles.css",
},
};
+5 -1
View File
@@ -32,7 +32,11 @@ const LinkRoot = ({children, className, ...props}: LinkRootProps) => {
return (
<LinkContext value={{slots}}>
<LinkPrimitive {...props} className={composeTwRenderProps(className, slots?.base())}>
<LinkPrimitive
data-slot="link"
{...props}
className={composeTwRenderProps(className, slots?.base())}
>
{(values) => <>{typeof children === "function" ? children(values) : children}</>}
</LinkPrimitive>
</LinkContext>
+16 -2
View File
@@ -118,15 +118,29 @@ const TabList = ({children, className, ...props}: TabListProps) => {
if (!el) return;
const size = isVertical ? el.clientHeight : el.clientWidth;
const scrollSize = isVertical ? el.scrollHeight : el.scrollWidth;
const maxScroll = Math.max(0, scrollSize - size);
// In RTL, the horizontal scroll range runs from 0 (start, on the right) to negative,
// so the delta sign must be flipped for `scrollLeft` to move toward the intended edge.
const isRTL = !isVertical && getComputedStyle(el).direction === "rtl";
const delta = direction * size * 0.8 * (isRTL ? -1 : 1);
const current = isVertical ? el.scrollTop : el.scrollLeft;
el.scrollBy({
// Clamp the target to the scrollable range so a press near the edge lands
// flush on it instead of requesting a position past the content, which can
// cut the smooth scroll short of the edge (e.g. iOS Safari) or leave the
// strip visually stranded. RTL ranges run [-maxScroll, 0].
const next = Math.min(
isRTL ? 0 : maxScroll,
Math.max(isRTL ? -maxScroll : 0, current + delta),
);
if (next === current) return;
el.scrollTo({
behavior: "smooth",
[isVertical ? "top" : "left"]: delta,
[isVertical ? "top" : "left"]: next,
});
},
[isVertical],
@@ -12,6 +12,7 @@ import {
OverlayArrow,
Tooltip as TooltipPrimitive,
TooltipTrigger as TooltipTriggerPrimitive,
TooltipTriggerStateContext,
} from "react-aria-components/Tooltip";
import {useCSSVariable} from "../../hooks/use-css-variable";
@@ -28,17 +29,49 @@ type TooltipContext = {
const TooltipContext = createContext<TooltipContext>({});
/* -------------------------------------------------------------------------------------------------
* Tooltip Animation Scope
* -----------------------------------------------------------------------------------------------*/
type TooltipAnimationScopeProps = {
children: ReactNode;
shouldSkipAnimation?: boolean;
};
// React Aria sets `shouldSkipAnimation` while its global warmup timer is active, which makes
// `Tooltip` drop `data-entering` / `data-exiting` when one tooltip replaces another. HeroUI drives
// both phases from CSS keyframes, so the flag is cleared for descendants to keep the fade on swap.
const TooltipAnimationScope = ({children, shouldSkipAnimation}: TooltipAnimationScopeProps) => {
const state = use(TooltipTriggerStateContext);
if (!state || shouldSkipAnimation) return children;
return (
<TooltipTriggerStateContext value={{...state, shouldSkipAnimation: false}}>
{children}
</TooltipTriggerStateContext>
);
};
/* -------------------------------------------------------------------------------------------------
* Tooltip Root
* -----------------------------------------------------------------------------------------------*/
type TooltipRootProps = ComponentPropsWithRef<typeof TooltipTriggerPrimitive>;
type TooltipRootProps = ComponentPropsWithRef<typeof TooltipTriggerPrimitive> & {
/**
* Whether to let React Aria skip the enter and exit animations while its global warmup timer is
* active, i.e. when moving quickly from one tooltip to another.
*
* @default false
*/
shouldSkipAnimation?: boolean;
};
const TooltipRoot = ({
children,
closeDelay,
delay,
shouldSkipAnimation = false,
...props
}: ComponentPropsWithRef<typeof TooltipTriggerPrimitive>) => {
}: TooltipRootProps) => {
const slots = React.useMemo(() => tooltipVariants(), []);
const cssDelay = useCSSVariable("--tooltip-delay");
@@ -55,7 +88,9 @@ const TooltipRoot = ({
delay={resolvedDelay}
{...props}
>
<TooltipAnimationScope shouldSkipAnimation={shouldSkipAnimation}>
{children}
</TooltipAnimationScope>
</TooltipTriggerPrimitive>
</TooltipContext>
);
@@ -0,0 +1,37 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {Accordion} from "@/components/accordion";
describe("Accordion SSR", () => {
it("renders without hydration mismatch with one expanded item", async () => {
const {html} = await ssrSmoke(
<Accordion data-testid="accordion" defaultExpandedKeys={["faq-1"]}>
<Accordion.Item id="faq-1">
<Accordion.Heading>
<Accordion.Trigger>
How do I place an order?
<Accordion.Indicator />
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
<Accordion.Body>Browse our products and proceed to checkout.</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item id="faq-2">
<Accordion.Heading>
<Accordion.Trigger>
Can I modify my order?
<Accordion.Indicator />
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
<Accordion.Body>Yes, before it ships.</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
</Accordion>,
);
expect(html).toContain('data-slot="accordion"');
expect(html).toContain('data-expanded="true"');
});
});
@@ -0,0 +1,177 @@
import type {ComponentProps} from "react";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {Accordion} from "@/components/accordion";
const renderAccordion = (props: ComponentProps<typeof Accordion> = {}) => {
return render(
<Accordion data-testid="accordion" {...props}>
<Accordion.Item id="faq-1">
<Accordion.Heading>
<Accordion.Trigger>
How do I place an order?
<Accordion.Indicator />
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
<Accordion.Body>Browse our products and proceed to checkout.</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item id="faq-2">
<Accordion.Heading>
<Accordion.Trigger>
Can I modify my order?
<Accordion.Indicator />
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
<Accordion.Body>Yes, before it ships.</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item isDisabled id="faq-3">
<Accordion.Heading>
<Accordion.Trigger>
Disabled question
<Accordion.Indicator />
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
<Accordion.Body>Never shown.</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
</Accordion>,
);
};
describe("Accordion", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("exposes data-slot, BEM block, and compound part slots", () => {
renderAccordion();
const root = screen.getByTestId("accordion");
expect(root).toHaveAttribute("data-slot", "accordion");
expect(root.className).toEqual(expect.stringContaining("accordion"));
expect(document.querySelectorAll('[data-slot="accordion-item"]')).toHaveLength(3);
expect(document.querySelectorAll('[data-slot="accordion-trigger"]')).toHaveLength(3);
expect(document.querySelectorAll('[data-slot="accordion-heading"]')).toHaveLength(3);
expect(document.querySelectorAll('[data-slot="accordion-panel"]')).toHaveLength(3);
expect(document.querySelectorAll('[data-slot="accordion-indicator"]')).toHaveLength(3);
});
it("exposes variant BEM modifier", () => {
renderAccordion({variant: "surface"});
expect(screen.getByTestId("accordion").className).toEqual(
expect.stringContaining("accordion--surface"),
);
});
it("supports expanding a panel via its trigger", async () => {
renderAccordion();
const trigger = screen.getByRole("button", {name: "How do I place an order?"});
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("Browse our products and proceed to checkout.")).not.toBeVisible();
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Browse our products and proceed to checkout.")).toBeVisible();
});
it("supports expanding a panel via keyboard (Enter/Space)", async () => {
renderAccordion();
const trigger = screen.getByRole("button", {name: "How do I place an order?"});
await user.tab();
expect(trigger).toHaveFocus();
expect(trigger).toHaveAttribute("aria-expanded", "false");
await user.keyboard("{Enter}");
expect(trigger).toHaveAttribute("aria-expanded", "true");
await user.keyboard(" ");
expect(trigger).toHaveAttribute("aria-expanded", "false");
});
it("supports single expanded item by default", async () => {
renderAccordion();
const first = screen.getByRole("button", {name: "How do I place an order?"});
const second = screen.getByRole("button", {name: "Can I modify my order?"});
await user.click(first);
expect(first).toHaveAttribute("aria-expanded", "true");
await user.click(second);
expect(second).toHaveAttribute("aria-expanded", "true");
expect(first).toHaveAttribute("aria-expanded", "false");
});
it("supports multiple expanded items with allowsMultipleExpanded", async () => {
renderAccordion({allowsMultipleExpanded: true});
const first = screen.getByRole("button", {name: "How do I place an order?"});
const second = screen.getByRole("button", {name: "Can I modify my order?"});
await user.click(first);
await user.click(second);
expect(first).toHaveAttribute("aria-expanded", "true");
expect(second).toHaveAttribute("aria-expanded", "true");
});
it("calls onExpandedChange with the expanded keys", async () => {
const onExpandedChange = vi.fn();
renderAccordion({onExpandedChange});
await user.click(screen.getByRole("button", {name: "How do I place an order?"}));
expect(onExpandedChange).toHaveBeenCalledWith(new Set(["faq-1"]));
});
it("supports disabled items without expanding", async () => {
renderAccordion();
const disabledTrigger = screen.getByRole("button", {name: "Disabled question"});
expect(disabledTrigger).toBeDisabled();
await user.click(disabledTrigger);
expect(disabledTrigger).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("Never shown.")).not.toBeVisible();
});
it("exposes data-expanded on the indicator", async () => {
renderAccordion();
const indicator = document.querySelector('[data-slot="accordion-indicator"]');
expect(indicator).not.toHaveAttribute("data-expanded");
await user.click(screen.getByRole("button", {name: "How do I place an order?"}));
expect(indicator).toHaveAttribute("data-expanded", "true");
});
it("exposes data-hide-separator when hideSeparator is set", () => {
renderAccordion({hideSeparator: true});
const items = document.querySelectorAll('[data-slot="accordion-item"]');
items.forEach((item) => {
expect(item).toHaveAttribute("data-hide-separator", "true");
});
});
});
@@ -0,0 +1,45 @@
import {render} from "@heroui/testing/browser";
import {isDocumentScrollLocked} from "@heroui/testing/helpers";
import {page, userEvent} from "vitest/browser";
import {AlertDialogFixture} from "./fixtures";
const renderAlertDialog = () => render(<AlertDialogFixture />);
describe("AlertDialog (browser)", () => {
it("supports focus trap and scroll lock while open", async () => {
await renderAlertDialog();
await page.getByRole("button", {name: "Delete Project"}).click();
const dialog = page.getByRole("alertdialog");
await expect.element(dialog).toBeInTheDocument();
expect(isDocumentScrollLocked()).toBe(true);
expect(dialog.element().contains(document.activeElement)).toBe(true);
await userEvent.tab();
expect(dialog.element().contains(document.activeElement)).toBe(true);
});
it("supports blocked Escape and CloseTrigger with focus restore", async () => {
await renderAlertDialog();
const trigger = page.getByRole("button", {name: "Delete Project"});
await trigger.click();
const dialog = page.getByRole("alertdialog");
await expect.element(dialog).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await expect.element(dialog).toBeInTheDocument();
await page.getByRole("button", {name: "Close"}).click();
await expect.element(dialog).not.toBeInTheDocument();
expect(isDocumentScrollLocked()).toBe(false);
await expect.element(trigger).toHaveFocus();
});
});
@@ -0,0 +1,13 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {AlertDialogFixture} from "./fixtures";
describe("AlertDialog SSR", () => {
it("renders without hydration mismatch when closed", async () => {
await ssrSmoke(<AlertDialogFixture />);
});
it("renders without hydration mismatch when defaultOpen", async () => {
await ssrSmoke(<AlertDialogFixture defaultOpen />);
});
});
@@ -0,0 +1,111 @@
import {User, cleanup, render, runAllTimers, screen, setupUser} from "@heroui/testing/helpers";
import {AlertDialogFixture} from "./fixtures";
const renderAlertDialog = (
props: {
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
isDismissable?: boolean;
isKeyboardDismissDisabled?: boolean;
} = {},
) => render(<AlertDialogFixture {...props} />);
describe("AlertDialog", () => {
let user: ReturnType<typeof setupUser>;
let testUtilUser: User;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
user = setupUser({advanceTimers: vi.advanceTimersByTime});
testUtilUser = new User({
interactionType: "mouse",
advanceTimer: vi.advanceTimersByTime,
});
});
afterEach(() => {
cleanup();
runAllTimers();
vi.useRealTimers();
});
it("exposes alertdialog role and compound slots", async () => {
renderAlertDialog();
const tester = testUtilUser.createTester("Dialog", {
root: screen.getByRole("button", {name: "Delete Project"}),
overlayType: "modal",
});
expect(tester.getDialog()).toBeNull();
await tester.open();
runAllTimers();
const dialog = tester.getDialog();
expect(dialog).not.toBeNull();
expect(dialog).toHaveAttribute("role", "alertdialog");
expect(dialog).toHaveAttribute("data-slot", "alert-dialog-dialog");
expect(document.querySelector('[data-slot="alert-dialog-backdrop"]')).not.toBeNull();
expect(document.querySelector('[data-slot="alert-dialog-container"]')).not.toBeNull();
expect(document.querySelector('[data-slot="alert-dialog-header"]')).not.toBeNull();
expect(document.querySelector('[data-slot="alert-dialog-body"]')).not.toBeNull();
expect(document.querySelector('[data-slot="alert-dialog-footer"]')).not.toBeNull();
expect(document.querySelector('[data-slot="alert-dialog-icon"]')).not.toBeNull();
expect(screen.getByRole("heading", {name: "Delete project permanently?"})).toBeInTheDocument();
await user.click(document.querySelector('[data-slot="alert-dialog-close-trigger"]')!);
runAllTimers();
expect(screen.queryByRole("alertdialog")).toBeNull();
});
it("supports blocked Escape by default", async () => {
const onOpenChange = vi.fn();
renderAlertDialog({defaultOpen: true, onOpenChange});
runAllTimers();
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
runAllTimers();
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it("supports closing via CloseTrigger", async () => {
const onOpenChange = vi.fn();
renderAlertDialog({defaultOpen: true, onOpenChange});
runAllTimers();
await user.click(document.querySelector('[data-slot="alert-dialog-close-trigger"]')!);
runAllTimers();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(screen.queryByRole("alertdialog")).toBeNull();
});
it("supports Escape dismiss when keyboard dismiss is enabled", async () => {
const onOpenChange = vi.fn();
renderAlertDialog({
defaultOpen: true,
onOpenChange,
isKeyboardDismissDisabled: false,
});
runAllTimers();
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
runAllTimers();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(screen.queryByRole("alertdialog")).toBeNull();
});
});
@@ -0,0 +1,40 @@
import {AlertDialog} from "@/components/alert-dialog";
import {Button} from "@/components/button";
export type AlertDialogFixtureProps = {
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
isDismissable?: boolean;
isKeyboardDismissDisabled?: boolean;
};
export const AlertDialogFixture = (props: AlertDialogFixtureProps = {}) => (
<AlertDialog defaultOpen={props.defaultOpen} onOpenChange={props.onOpenChange}>
<Button>Delete Project</Button>
<AlertDialog.Backdrop
isDismissable={props.isDismissable}
isKeyboardDismissDisabled={props.isKeyboardDismissDisabled}
>
<AlertDialog.Container>
<AlertDialog.Dialog>
<AlertDialog.CloseTrigger />
<AlertDialog.Header>
<AlertDialog.Icon status="danger" />
<AlertDialog.Heading>Delete project permanently?</AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body>
<p>This action cannot be undone.</p>
</AlertDialog.Body>
<AlertDialog.Footer>
<Button slot="close" variant="tertiary">
Cancel
</Button>
<Button slot="close" variant="danger">
Delete Project
</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
</AlertDialog>
);
@@ -0,0 +1,100 @@
import {render, screen} from "@heroui/testing/helpers";
import {Alert} from "@/components/alert";
describe("Alert", () => {
it("exposes data-slot and BEM block", () => {
render(
<Alert>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>New features available</Alert.Title>
<Alert.Description>Check out our latest updates.</Alert.Description>
</Alert.Content>
</Alert>,
);
const root = document.querySelector('[data-slot="alert-root"]');
expect(root).not.toBeNull();
expect(root?.className).toEqual(expect.stringContaining("alert"));
expect(document.querySelector('[data-slot="alert-indicator"]')).not.toBeNull();
expect(document.querySelector('[data-slot="alert-content"]')).not.toBeNull();
expect(screen.getByText("New features available")).toBeInTheDocument();
expect(screen.getByText("Check out our latest updates.")).toBeInTheDocument();
});
it("exposes default status BEM modifier", () => {
render(
<Alert>
<Alert.Content>
<Alert.Title>Default</Alert.Title>
</Alert.Content>
</Alert>,
);
expect(document.querySelector('[data-slot="alert-root"]')?.className).toEqual(
expect.stringContaining("alert--default"),
);
});
it.each(["accent", "success", "warning", "danger"] as const)(
"maps status=%s to the matching BEM modifier",
(status) => {
render(
<Alert status={status}>
<Alert.Content>
<Alert.Title>Status alert</Alert.Title>
</Alert.Content>
</Alert>,
);
expect(document.querySelector('[data-slot="alert-root"]')?.className).toEqual(
expect.stringContaining(`alert--${status}`),
);
},
);
it("renders a default icon per status when no indicator children are given", () => {
render(
<Alert status="danger">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Unable to connect</Alert.Title>
</Alert.Content>
</Alert>,
);
expect(document.querySelector('[data-slot="alert-default-icon"]')).not.toBeNull();
});
it("renders custom indicator children instead of the default icon", () => {
render(
<Alert status="accent">
<Alert.Indicator>
<span data-testid="custom-indicator">Loading</span>
</Alert.Indicator>
<Alert.Content>
<Alert.Title>Processing</Alert.Title>
</Alert.Content>
</Alert>,
);
expect(screen.getByTestId("custom-indicator")).toBeInTheDocument();
expect(document.querySelector('[data-slot="alert-default-icon"]')).toBeNull();
});
it("renders without a description", () => {
render(
<Alert status="success">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Profile updated successfully</Alert.Title>
</Alert.Content>
</Alert>,
);
expect(screen.getByText("Profile updated successfully")).toBeInTheDocument();
expect(document.querySelector('[data-slot="alert-description"]')).toBeNull();
});
});
@@ -0,0 +1,64 @@
import type {ReactNode} from "react";
import {ssrSmoke} from "@heroui/testing/helpers";
import {useFilter} from "react-aria-components/Autocomplete";
import {Autocomplete} from "@/components/autocomplete";
import {Label} from "@/components/label";
import {ListBox} from "@/components/list-box";
import {I18nProvider} from "@/components/rac";
import {SearchField} from "@/components/search-field";
const wrapper = ({children}: {children: ReactNode}) => (
<I18nProvider locale="en-US">{children}</I18nProvider>
);
const AutocompleteExample = ({defaultOpen}: {defaultOpen?: boolean}) => {
const {contains} = useFilter({sensitivity: "base"});
return (
<Autocomplete
data-testid="autocomplete"
defaultOpen={defaultOpen}
placeholder="Select an animal"
selectionMode="single"
>
<Label>Favorite Animal</Label>
<Autocomplete.Trigger>
<Autocomplete.Value />
<Autocomplete.Indicator />
</Autocomplete.Trigger>
<Autocomplete.Popover>
<Autocomplete.Filter filter={contains}>
<SearchField autoFocus aria-label="Search animals" name="search" variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder="Search animals..." />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<ListBox>
<ListBox.Item id="cat" textValue="Cat">
Cat
<ListBox.ItemIndicator />
</ListBox.Item>
<ListBox.Item id="dog" textValue="Dog">
Dog
<ListBox.ItemIndicator />
</ListBox.Item>
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
};
describe("Autocomplete SSR", () => {
it("renders without hydration mismatch when closed", async () => {
await ssrSmoke(<AutocompleteExample />, {wrapper});
});
it("renders without hydration mismatch when defaultOpen", async () => {
await ssrSmoke(<AutocompleteExample defaultOpen />, {wrapper});
});
});
@@ -0,0 +1,174 @@
import type {Key} from "@react-types/shared";
import {User, cleanup, render, runAllTimers, screen, setupUser} from "@heroui/testing/helpers";
import {useFilter} from "react-aria-components/Autocomplete";
import {Autocomplete} from "@/components/autocomplete";
import {FieldError} from "@/components/field-error";
import {Label} from "@/components/label";
import {ListBox} from "@/components/list-box";
import {SearchField} from "@/components/search-field";
const animals = [
{id: "cat", name: "Cat"},
{id: "dog", name: "Dog"},
{id: "panda", name: "Panda"},
];
const AutocompleteExample = (props: {
isDisabled?: boolean;
isInvalid?: boolean;
value?: Key | null;
defaultValue?: Key;
onChange?: (key: Key | null) => void;
onClear?: () => void;
withClearButton?: boolean;
}) => {
const {contains} = useFilter({sensitivity: "base"});
return (
<Autocomplete
data-testid="autocomplete"
defaultValue={props.defaultValue}
isDisabled={props.isDisabled}
isInvalid={props.isInvalid}
placeholder="Select an animal"
selectionMode="single"
value={props.value}
onChange={props.onChange}
onClear={props.onClear}
>
<Label>Favorite Animal</Label>
<Autocomplete.Trigger>
<Autocomplete.Value />
{props.withClearButton ? <Autocomplete.ClearButton /> : null}
<Autocomplete.Indicator />
</Autocomplete.Trigger>
<Autocomplete.Popover>
<Autocomplete.Filter filter={contains}>
<SearchField autoFocus aria-label="Search animals" name="search" variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder="Search animals..." />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<ListBox>
{animals.map((item) => (
<ListBox.Item key={item.id} id={item.id} textValue={item.name}>
{item.name}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
{props.isInvalid ? <FieldError>Please choose an animal</FieldError> : null}
</Autocomplete>
);
};
describe("Autocomplete", () => {
let user: ReturnType<typeof setupUser>;
let testUtilUser: User;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
user = setupUser({advanceTimers: vi.advanceTimersByTime});
testUtilUser = new User({
interactionType: "mouse",
advanceTimer: vi.advanceTimersByTime,
});
});
afterEach(() => {
cleanup();
runAllTimers();
vi.useRealTimers();
});
it("exposes data-slots and BEM block", () => {
render(<AutocompleteExample />);
const root = screen.getByTestId("autocomplete");
expect(root).toHaveAttribute("data-slot", "autocomplete");
expect(root.className).toEqual(expect.stringContaining("autocomplete"));
expect(document.querySelector('[data-slot="autocomplete-trigger"]')).not.toBeNull();
expect(document.querySelector('[data-slot="autocomplete-value"]')).not.toBeNull();
});
it("supports open, select, and value update via Select tester", async () => {
const onChange = vi.fn();
render(<AutocompleteExample onChange={onChange} />);
const tester = testUtilUser.createTester("Select", {
root: screen.getByTestId("autocomplete"),
});
expect(tester.getListbox()).toBeNull();
await tester.open();
runAllTimers();
expect(tester.getListbox()).not.toBeNull();
expect(document.querySelector('[data-slot="autocomplete-popover"]')).not.toBeNull();
expect(screen.getByPlaceholderText("Search animals...")).toBeInTheDocument();
expect(tester.getOptions()).toHaveLength(3);
await tester.toggleOptionSelection({option: "Dog"});
runAllTimers();
expect(onChange).toHaveBeenCalledWith("dog");
expect(tester.getListbox()).toBeNull();
expect(screen.getByTestId("autocomplete")).toHaveTextContent("Dog");
});
it("exposes ClearButton and calls onClear", async () => {
const onClear = vi.fn();
const onChange = vi.fn();
render(
<AutocompleteExample
withClearButton
defaultValue="cat"
onChange={onChange}
onClear={onClear}
/>,
);
expect(document.querySelector('[data-slot="autocomplete-clear-button"]')).not.toBeNull();
await user.click(document.querySelector('[data-slot="autocomplete-clear-button"]')!);
runAllTimers();
expect(onClear).toHaveBeenCalledTimes(1);
});
it("supports disabled state", async () => {
const onChange = vi.fn();
render(<AutocompleteExample isDisabled onChange={onChange} />);
const tester = testUtilUser.createTester("Select", {
root: screen.getByTestId("autocomplete"),
});
expect(tester.getTrigger()).toBeDisabled();
await tester.open();
runAllTimers();
expect(tester.getListbox()).toBeNull();
expect(onChange).not.toHaveBeenCalled();
});
it("renders FieldError when invalid", () => {
render(<AutocompleteExample isInvalid />);
expect(screen.getByText("Please choose an animal")).toBeInTheDocument();
expect(document.querySelector('[data-slot="field-error"]')).not.toBeNull();
expect(screen.getByTestId("autocomplete")).toHaveAttribute("data-invalid", "true");
});
});
@@ -0,0 +1,59 @@
import {render, screen} from "@heroui/testing/helpers";
import {Avatar} from "@/components/avatar";
describe("Avatar", () => {
it("renders fallback text content", () => {
render(
<Avatar>
<Avatar.Fallback>JD</Avatar.Fallback>
</Avatar>,
);
expect(screen.getByText("JD")).toBeInTheDocument();
});
it("exposes BEM block class on the root", () => {
render(
<Avatar data-testid="avatar">
<Avatar.Fallback>JD</Avatar.Fallback>
</Avatar>,
);
expect(screen.getByTestId("avatar").className).toEqual(expect.stringContaining("avatar"));
});
it("exposes color fallback BEM modifier", () => {
render(
<Avatar color="accent">
<Avatar.Fallback data-testid="fallback">AC</Avatar.Fallback>
</Avatar>,
);
expect(screen.getByTestId("fallback").className).toEqual(
expect.stringContaining("avatar__fallback--accent"),
);
});
it("supports data attribute passthrough", () => {
render(
<Avatar data-foo="bar" data-testid="avatar">
<Avatar.Fallback>JD</Avatar.Fallback>
</Avatar>,
);
expect(screen.getByTestId("avatar")).toHaveAttribute("data-foo", "bar");
});
describe("Avatar.Fallback", () => {
it("exposes data-slot when composed", () => {
render(
<Avatar>
<Avatar.Fallback>JD</Avatar.Fallback>
</Avatar>,
);
expect(document.querySelector('[data-slot="avatar-fallback"]')).not.toBeNull();
});
});
});
@@ -0,0 +1,58 @@
import {render, screen} from "@heroui/testing/helpers";
import {Badge} from "@/components/badge";
describe("Badge", () => {
it("renders text content", () => {
render(<Badge>5</Badge>);
expect(screen.getByText("5")).toBeInTheDocument();
});
it("exposes BEM block and data-slot", () => {
render(<Badge data-testid="badge">5</Badge>);
const badge = screen.getByTestId("badge");
expect(badge).toHaveAttribute("data-slot", "badge");
expect(badge.className).toEqual(expect.stringContaining("badge"));
});
it("exposes color BEM modifier", () => {
render(
<Badge color="accent" data-testid="badge">
5
</Badge>,
);
expect(screen.getByTestId("badge").className).toEqual(expect.stringContaining("badge--accent"));
});
it("supports data attribute passthrough", () => {
render(
<Badge data-foo="bar" data-testid="badge">
5
</Badge>,
);
expect(screen.getByTestId("badge")).toHaveAttribute("data-foo", "bar");
});
it("renders string children in a labeled sub-part", () => {
render(<Badge>New</Badge>);
expect(document.querySelector('[data-slot="badge-label"]')).not.toBeNull();
});
describe("Badge.Anchor", () => {
it("exposes data-slot when composing with a badge", () => {
render(
<Badge.Anchor data-testid="anchor">
<span>Avatar</span>
<Badge>5</Badge>
</Badge.Anchor>,
);
expect(screen.getByTestId("anchor")).toHaveAttribute("data-slot", "badge-anchor");
});
});
});
@@ -0,0 +1,93 @@
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {Breadcrumbs} from "@/components/breadcrumbs";
const renderBreadcrumbs = (
props: Partial<{isDisabled: boolean; separator: React.ReactNode}> = {},
) =>
render(
<Breadcrumbs
data-testid="breadcrumbs"
isDisabled={props.isDisabled}
separator={props.separator}
>
<Breadcrumbs.Item href="#home">Home</Breadcrumbs.Item>
<Breadcrumbs.Item href="#products">Products</Breadcrumbs.Item>
<Breadcrumbs.Item>Laptop</Breadcrumbs.Item>
</Breadcrumbs>,
);
describe("Breadcrumbs", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("exposes data-slot, BEM block, and a labelled list role", () => {
renderBreadcrumbs();
const root = screen.getByTestId("breadcrumbs");
expect(root).toHaveAttribute("data-slot", "breadcrumbs");
expect(root.className).toEqual(expect.stringContaining("breadcrumbs"));
expect(screen.getByRole("list")).toBe(root);
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});
it("renders each item as a link with data-slot", () => {
renderBreadcrumbs();
expect(document.querySelectorAll('[data-slot="breadcrumbs-item"]')).toHaveLength(3);
expect(screen.getByRole("link", {name: "Home"})).toHaveAttribute("href", "#home");
expect(screen.getByRole("link", {name: "Products"})).toHaveAttribute("href", "#products");
});
it("exposes current page on the last item without separator", () => {
renderBreadcrumbs();
const current = screen.getByText("Laptop");
expect(current.closest("li")).toHaveAttribute("data-current", "true");
expect(screen.getByRole("link", {name: "Laptop"})).toHaveAttribute("aria-current", "page");
});
it("renders a default chevron separator between non-final items", () => {
renderBreadcrumbs();
expect(document.querySelectorAll('[data-slot="breadcrumbs-separator"]')).toHaveLength(2);
});
it("supports a custom separator", () => {
renderBreadcrumbs({separator: <span data-testid="custom-separator">/</span>});
const separators = document.querySelectorAll('[data-slot="breadcrumbs-separator"]');
expect(separators).toHaveLength(2);
expect(screen.getAllByTestId("custom-separator")).toHaveLength(2);
});
it("exposes aria-disabled on links when isDisabled", () => {
renderBreadcrumbs({isDisabled: true});
expect(screen.getByRole("link", {name: "Home"})).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("link", {name: "Products"})).toHaveAttribute("aria-disabled", "true");
});
it("calls onPress when a non-current item is activated", async () => {
const onPress = vi.fn();
render(
<Breadcrumbs>
<Breadcrumbs.Item href="#home" onPress={onPress}>
Home
</Breadcrumbs.Item>
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
</Breadcrumbs>,
);
await user.click(screen.getByRole("link", {name: "Home"}));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,132 @@
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {Button} from "@/components/button";
import {ButtonGroup} from "@/components/button-group";
describe("ButtonGroup", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("renders with role=group and data-slot", () => {
render(
<ButtonGroup>
<Button>One</Button>
<Button>Two</Button>
</ButtonGroup>,
);
const group = screen.getByRole("group");
expect(group).toHaveAttribute("data-slot", "button-group");
expect(group.className).toEqual(expect.stringContaining("button-group"));
});
it("exposes orientation BEM modifiers", () => {
const {rerender} = render(
<ButtonGroup>
<Button>One</Button>
</ButtonGroup>,
);
expect(screen.getByRole("group").className).toEqual(
expect.stringContaining("button-group--horizontal"),
);
rerender(
<ButtonGroup orientation="vertical">
<Button>One</Button>
</ButtonGroup>,
);
expect(screen.getByRole("group").className).toEqual(
expect.stringContaining("button-group--vertical"),
);
});
it("renders Separator with data-slot", () => {
render(
<ButtonGroup>
<Button>One</Button>
<ButtonGroup.Separator />
<Button>Two</Button>
</ButtonGroup>,
);
const separator = document.querySelector('[data-slot="button-group-separator"]');
expect(separator).not.toBeNull();
expect(separator).toHaveAttribute("aria-hidden", "true");
});
it("exposes inherited size and variant on direct Button children", () => {
render(
<ButtonGroup size="sm" variant="secondary">
<Button>Save</Button>
</ButtonGroup>,
);
const button = screen.getByRole("button", {name: "Save"});
expect(button.className).toEqual(expect.stringContaining("button--sm"));
expect(button.className).toEqual(expect.stringContaining("button--secondary"));
});
it("exposes inherited isDisabled on direct Button children", () => {
render(
<ButtonGroup isDisabled>
<Button>Save</Button>
</ButtonGroup>,
);
expect(screen.getByRole("button", {name: "Save"})).toBeDisabled();
});
it("supports child Button props overriding group defaults", () => {
render(
<ButtonGroup size="sm" variant="secondary">
<Button size="lg" variant="primary">
Override
</Button>
</ButtonGroup>,
);
const button = screen.getByRole("button", {name: "Override"});
expect(button.className).toEqual(expect.stringContaining("button--lg"));
expect(button.className).toEqual(expect.stringContaining("button--primary"));
expect(button.className).not.toEqual(expect.stringContaining("button--sm"));
});
it("exposes no inherited context on nested non-direct Buttons", () => {
render(
<ButtonGroup size="sm" variant="secondary">
<div>
<Button>Nested</Button>
</div>
</ButtonGroup>,
);
const button = screen.getByRole("button", {name: "Nested"});
expect(button.className).not.toEqual(expect.stringContaining("button--sm"));
expect(button.className).not.toEqual(expect.stringContaining("button--secondary"));
});
it("supports pressing grouped buttons", async () => {
const onPress = vi.fn();
render(
<ButtonGroup>
<Button onPress={onPress}>Save</Button>
<Button>Cancel</Button>
</ButtonGroup>,
);
await user.click(screen.getByRole("button", {name: "Save"}));
expect(onPress).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,17 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {Button} from "@/components/button";
describe("Button SSR", () => {
it("renders without hydration mismatch", async () => {
const {html} = await ssrSmoke(<Button>Save</Button>);
expect(html).toContain('data-slot="button"');
});
it("renders without hydration mismatch when disabled", async () => {
const {html} = await ssrSmoke(<Button isDisabled>Save</Button>);
expect(html).toContain('data-disabled="true"');
});
});
@@ -0,0 +1,155 @@
import {fireEvent, render, screen, setupUser} from "@heroui/testing/helpers";
import {Button} from "@/components/button";
const pointerOpts = {
button: 0,
pointerId: 1,
pointerType: "mouse",
clientX: 1,
clientY: 1,
} as const;
// RAC usePress sequence in jsdom.
const pressDown = (element: HTMLElement) => fireEvent.pointerDown(element, pointerOpts);
const pressUp = (element: HTMLElement) => {
fireEvent.pointerUp(element, pointerOpts);
fireEvent.click(element, {button: 0});
};
describe("Button", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("renders with role and accessible name", () => {
render(<Button>Save</Button>);
expect(screen.getByRole("button", {name: "Save"})).toBeInTheDocument();
});
it("exposes BEM block and data-slot", () => {
render(<Button>Save</Button>);
const button = screen.getByRole("button", {name: "Save"});
expect(button.className).toEqual(expect.stringContaining("button"));
expect(button).toHaveAttribute("data-slot", "button");
});
it("exposes variant BEM modifier", () => {
render(<Button variant="secondary">Save</Button>);
expect(screen.getByRole("button", {name: "Save"}).className).toEqual(
expect.stringContaining("button--secondary"),
);
});
it("supports data attribute passthrough", () => {
render(<Button data-testid="save-btn">Save</Button>);
expect(screen.getByTestId("save-btn")).toBeInTheDocument();
});
it("supports hover state", async () => {
render(<Button>Save</Button>);
const button = screen.getByRole("button", {name: "Save"});
expect(button).not.toHaveAttribute("data-hovered");
await user.hover(button);
expect(button).toHaveAttribute("data-hovered", "true");
await user.unhover(button);
expect(button).not.toHaveAttribute("data-hovered");
});
it("supports press state", () => {
render(<Button>Save</Button>);
const button = screen.getByRole("button", {name: "Save"});
expect(button).not.toHaveAttribute("data-pressed");
pressDown(button);
expect(button).toHaveAttribute("data-pressed", "true");
pressUp(button);
expect(button).not.toHaveAttribute("data-pressed");
});
it("calls onPress on click", async () => {
const onPress = vi.fn();
render(<Button onPress={onPress}>Save</Button>);
await user.click(screen.getByRole("button", {name: "Save"}));
expect(onPress).toHaveBeenCalledTimes(1);
});
it("supports focus-visible via keyboard", async () => {
render(<Button>Save</Button>);
const button = screen.getByRole("button", {name: "Save"});
expect(button).not.toHaveAttribute("data-focus-visible");
await user.tab();
expect(button).toHaveFocus();
expect(button).toHaveAttribute("data-focus-visible", "true");
await user.tab();
expect(button).not.toHaveAttribute("data-focus-visible");
});
it("supports disabled state and blocks press", async () => {
const onPress = vi.fn();
render(
<Button isDisabled onPress={onPress}>
Save
</Button>,
);
const button = screen.getByRole("button", {name: "Save"});
expect(button).toBeDisabled();
expect(button).toHaveAttribute("data-disabled", "true");
await user.click(button);
expect(onPress).not.toHaveBeenCalled();
});
it("exposes no hover state when disabled", async () => {
render(<Button isDisabled>Save</Button>);
const button = screen.getByRole("button", {name: "Save"});
await user.hover(button);
expect(button).not.toHaveAttribute("data-hovered");
});
it("supports keyboard activation with Enter and Space", async () => {
const onPress = vi.fn();
render(<Button onPress={onPress}>Save</Button>);
const button = screen.getByRole("button", {name: "Save"});
await user.tab();
expect(button).toHaveFocus();
await user.keyboard("{Enter}");
expect(onPress).toHaveBeenCalledTimes(1);
await user.keyboard(" ");
expect(onPress).toHaveBeenCalledTimes(2);
});
it("supports render-prop children for press state", () => {
render(<Button>{({isPressed}) => (isPressed ? "Pressed" : "Save")}</Button>);
const button = screen.getByRole("button", {name: "Save"});
pressDown(button);
expect(button).toHaveTextContent("Pressed");
pressUp(button);
expect(button).toHaveTextContent("Save");
});
});
@@ -0,0 +1,70 @@
import type {ReactNode} from "react";
import {ssrSmoke} from "@heroui/testing/helpers";
import {CalendarDate} from "@internationalized/date";
import {Calendar} from "@/components/calendar";
import {I18nProvider} from "@/components/rac";
const MIN_VALUE = new CalendarDate(2020, 1, 1);
const MAX_VALUE = new CalendarDate(2030, 12, 31);
const wrapper = ({children}: {children: ReactNode}) => (
<I18nProvider locale="en-US">{children}</I18nProvider>
);
const CalendarExample = ({defaultYearPickerOpen}: {defaultYearPickerOpen?: boolean}) => (
<Calendar
aria-label="Event date"
defaultValue={new CalendarDate(2026, 8, 15)}
defaultYearPickerOpen={defaultYearPickerOpen}
maxValue={MAX_VALUE}
minValue={MIN_VALUE}
>
<Calendar.Header>
<Calendar.YearPickerTrigger>
<Calendar.YearPickerTriggerHeading />
<Calendar.YearPickerTriggerIndicator />
</Calendar.YearPickerTrigger>
<Calendar.NavButton slot="previous" />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>
{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}
</Calendar.GridHeader>
<Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody>
</Calendar.Grid>
<Calendar.YearPickerGrid>
<Calendar.YearPickerGridBody>
{({year}) => <Calendar.YearPickerCell year={year} />}
</Calendar.YearPickerGridBody>
</Calendar.YearPickerGrid>
</Calendar>
);
describe("Calendar SSR", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2026-08-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("renders and hydrates a controlled month view without mismatches", async () => {
const {html} = await ssrSmoke(<CalendarExample />, {wrapper});
expect(html).toContain('data-slot="calendar"');
expect(html).toContain('role="grid"');
expect(html).toContain('data-slot="calendar-cell"');
});
it("renders and hydrates with the year picker open (frozen system time)", async () => {
const {html} = await ssrSmoke(<CalendarExample defaultYearPickerOpen />, {wrapper});
expect(html).toContain('data-slot="calendar-year-picker-grid"');
expect(html).toContain('data-open="true"');
expect(html).toContain("2026");
});
});
@@ -0,0 +1,267 @@
import type {DateValue} from "@internationalized/date";
import type {ComponentProps} from "react";
import {act, render, screen, setupUser} from "@heroui/testing/helpers";
import {CalendarDate} from "@internationalized/date";
import {Calendar} from "@/components/calendar";
const MIN_VALUE = new CalendarDate(2020, 1, 1);
const MAX_VALUE = new CalendarDate(2030, 12, 31);
const renderCalendar = (props: Partial<ComponentProps<typeof Calendar>> = {}) =>
render(
<Calendar
aria-label="Event date"
defaultValue={new CalendarDate(2026, 8, 15)}
maxValue={MAX_VALUE}
minValue={MIN_VALUE}
{...props}
>
<Calendar.Header>
<Calendar.YearPickerTrigger>
<Calendar.YearPickerTriggerHeading />
<Calendar.YearPickerTriggerIndicator />
</Calendar.YearPickerTrigger>
<Calendar.NavButton slot="previous" />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>
{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}
</Calendar.GridHeader>
<Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody>
</Calendar.Grid>
<Calendar.YearPickerGrid>
<Calendar.YearPickerGridBody>
{({year}) => <Calendar.YearPickerCell year={year} />}
</Calendar.YearPickerGridBody>
</Calendar.YearPickerGrid>
</Calendar>,
);
const getDayCell = (label: string) =>
screen.getByRole("button", {name: (accessibleName) => accessibleName.includes(label)});
describe("Calendar", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("renders a grid with an accessible name", () => {
renderCalendar();
expect(screen.getByRole("grid", {name: /Event date/})).toBeInTheDocument();
});
it("exposes data-slots and BEM block", () => {
renderCalendar();
expect(document.querySelector('[data-slot="calendar"]')?.className).toEqual(
expect.stringContaining("calendar"),
);
expect(document.querySelector('[data-slot="calendar-header"]')).not.toBeNull();
expect(document.querySelector('[data-slot="calendar-grid"]')).not.toBeNull();
expect(document.querySelector('[data-slot="calendar-grid-header"]')).not.toBeNull();
expect(document.querySelector('[data-slot="calendar-grid-body"]')).not.toBeNull();
expect(document.querySelectorAll('[data-slot="calendar-cell"]').length).toBeGreaterThan(0);
});
describe("day selection", () => {
it("exposes aria-selected and data-selected on the selected day", () => {
renderCalendar();
const selected = getDayCell("August 15, 2026");
expect(selected.closest('[role="gridcell"]')).toHaveAttribute("aria-selected", "true");
expect(selected).toHaveAttribute("data-selected", "true");
});
it("calls onChange when selecting a new day", async () => {
const onChange = vi.fn();
renderCalendar({onChange});
await user.click(getDayCell("August 20, 2026"));
expect(onChange).toHaveBeenCalledTimes(1);
const value = onChange.mock.calls[0]?.[0] as DateValue;
expect(value.toString()).toBe("2026-08-20");
});
it("supports controlled value", () => {
const onChange = vi.fn();
const {rerender} = render(
<Calendar
aria-label="Event date"
maxValue={MAX_VALUE}
minValue={MIN_VALUE}
value={new CalendarDate(2026, 8, 15)}
onChange={onChange}
>
<Calendar.Header>
<Calendar.Heading />
<Calendar.NavButton slot="previous" />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>
{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}
</Calendar.GridHeader>
<Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody>
</Calendar.Grid>
</Calendar>,
);
expect(getDayCell("August 15, 2026")).toHaveAttribute("data-selected", "true");
rerender(
<Calendar
aria-label="Event date"
maxValue={MAX_VALUE}
minValue={MIN_VALUE}
value={new CalendarDate(2026, 8, 20)}
onChange={onChange}
>
<Calendar.Header>
<Calendar.Heading />
<Calendar.NavButton slot="previous" />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>
{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}
</Calendar.GridHeader>
<Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody>
</Calendar.Grid>
</Calendar>,
);
expect(getDayCell("August 20, 2026")).toHaveAttribute("data-selected", "true");
});
it("supports ArrowRight to move focus to the next day", async () => {
renderCalendar();
const currentCell = getDayCell("August 15, 2026");
await act(async () => {
currentCell.focus();
});
expect(currentCell).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(getDayCell("August 16, 2026")).toHaveFocus();
});
it("supports isDisabled to block day selection", async () => {
const onChange = vi.fn();
renderCalendar({isDisabled: true, onChange});
expect(document.querySelector('[data-slot="calendar"]')).toHaveAttribute(
"data-disabled",
"true",
);
await user.click(getDayCell("August 20, 2026"));
expect(onChange).not.toHaveBeenCalled();
});
});
describe("navigation", () => {
it("supports next and previous month via NavButton", async () => {
renderCalendar();
expect(screen.getByRole("grid", {name: /August 2026/})).toBeInTheDocument();
const nextButton = document.querySelector(
'[data-slot="calendar-nav-button"][slot="next"]',
) as HTMLElement;
const previousButton = document.querySelector(
'[data-slot="calendar-nav-button"][slot="previous"]',
) as HTMLElement;
await user.click(nextButton);
expect(screen.getByRole("grid", {name: /September 2026/})).toBeInTheDocument();
await user.click(previousButton);
expect(screen.getByRole("grid", {name: /August 2026/})).toBeInTheDocument();
});
});
describe("year picker", () => {
it("supports opening the year picker from the trigger", async () => {
renderCalendar();
const trigger = screen.getByRole("button", {name: /year selector/});
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(document.querySelector('[data-slot="calendar-year-picker-grid"]')).toHaveAttribute(
"aria-hidden",
"true",
);
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(document.querySelector('[data-slot="calendar-year-picker-grid"]')).not.toHaveAttribute(
"aria-hidden",
"true",
);
});
it("supports selecting a year and closing the picker", async () => {
renderCalendar();
await user.click(screen.getByRole("button", {name: /year selector/}));
await user.click(screen.getByRole("button", {name: "2028"}));
expect(screen.getByRole("grid", {name: /2028/})).toBeInTheDocument();
expect(screen.getByRole("button", {name: /year selector/})).toHaveAttribute(
"aria-expanded",
"false",
);
expect(document.querySelector('[data-slot="calendar-year-picker-grid"]')).toHaveAttribute(
"aria-hidden",
"true",
);
});
it("exposes data-selected on the focused year", async () => {
renderCalendar();
await user.click(screen.getByRole("button", {name: /year selector/}));
expect(screen.getByRole("button", {name: "2026"})).toHaveAttribute("data-selected", "true");
});
it("supports Escape dismiss for the year picker", async () => {
renderCalendar();
const trigger = screen.getByRole("button", {name: /year selector/});
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
await user.keyboard("{Escape}");
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(document.querySelector('[data-slot="calendar-year-picker-grid"]')).toHaveAttribute(
"aria-hidden",
"true",
);
});
});
it("supports focus-visible via keyboard", async () => {
renderCalendar();
await user.tab();
expect(document.querySelector("[data-focus-visible='true']")).not.toBeNull();
});
});
@@ -0,0 +1,74 @@
import {render, screen} from "@heroui/testing/helpers";
import {Card} from "@/components/card";
describe("Card", () => {
it("renders composed content", () => {
render(
<Card>
<Card.Header>
<Card.Title>Title</Card.Title>
<Card.Description>Description</Card.Description>
</Card.Header>
<Card.Content>Content</Card.Content>
<Card.Footer>Footer</Card.Footer>
</Card>,
);
expect(screen.getByText("Title")).toBeInTheDocument();
expect(screen.getByText("Description")).toBeInTheDocument();
expect(screen.getByText("Content")).toBeInTheDocument();
expect(screen.getByText("Footer")).toBeInTheDocument();
});
it("exposes BEM block and data-slot", () => {
render(<Card data-testid="card">Body</Card>);
const card = screen.getByTestId("card");
expect(card).toHaveAttribute("data-slot", "card");
expect(card.className).toEqual(expect.stringContaining("card"));
});
it("exposes variant BEM modifier", () => {
render(
<Card data-testid="card" variant="secondary">
Body
</Card>,
);
expect(screen.getByTestId("card").className).toEqual(
expect.stringContaining("card--secondary"),
);
});
it("supports data attribute passthrough", () => {
render(
<Card data-foo="bar" data-testid="card">
Body
</Card>,
);
expect(screen.getByTestId("card")).toHaveAttribute("data-foo", "bar");
});
describe("composition", () => {
it("exposes data-slot on each sub-part", () => {
render(
<Card>
<Card.Header data-testid="header">
<Card.Title data-testid="title">Title</Card.Title>
<Card.Description data-testid="description">Description</Card.Description>
</Card.Header>
<Card.Content data-testid="content">Content</Card.Content>
<Card.Footer data-testid="footer">Footer</Card.Footer>
</Card>,
);
expect(screen.getByTestId("header")).toHaveAttribute("data-slot", "card-header");
expect(screen.getByTestId("title")).toHaveAttribute("data-slot", "card-title");
expect(screen.getByTestId("description")).toHaveAttribute("data-slot", "card-description");
expect(screen.getByTestId("content")).toHaveAttribute("data-slot", "card-content");
expect(screen.getByTestId("footer")).toHaveAttribute("data-slot", "card-footer");
});
});
});
@@ -0,0 +1,130 @@
import {User, render, screen, setupUser} from "@heroui/testing/helpers";
import {Checkbox} from "@/components/checkbox";
import {CheckboxGroup} from "@/components/checkbox-group";
import {Description} from "@/components/description";
import {FieldError} from "@/components/field-error";
import {Label} from "@/components/label";
const renderInterests = (
props: {
isDisabled?: boolean;
isInvalid?: boolean;
onChange?: (value: string[]) => void;
defaultValue?: string[];
variant?: "primary" | "secondary";
} = {},
) =>
render(
<CheckboxGroup
data-testid="interests"
defaultValue={props.defaultValue}
isDisabled={props.isDisabled}
isInvalid={props.isInvalid}
name="interests"
variant={props.variant}
onChange={props.onChange}
>
<Label>Select your interests</Label>
<Description>Choose all that apply</Description>
<Checkbox value="coding">
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
Coding
</Checkbox.Content>
</Checkbox>
<Checkbox value="design">
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
Design
</Checkbox.Content>
</Checkbox>
{props.isInvalid ? <FieldError>Pick at least one</FieldError> : null}
</CheckboxGroup>,
);
describe("CheckboxGroup", () => {
let user: ReturnType<typeof setupUser>;
let testUtilUser: User;
beforeAll(() => {
user = setupUser();
testUtilUser = new User({interactionType: "mouse"});
});
it("exposes group role, BEM, and data-slot", () => {
renderInterests();
const group = screen.getByRole("group", {name: "Select your interests"});
expect(group).toHaveAttribute("data-slot", "checkbox-group");
expect(group.className).toEqual(expect.stringContaining("checkbox-group"));
expect(screen.getByRole("checkbox", {name: "Coding"})).toBeInTheDocument();
expect(screen.getByText("Choose all that apply")).toBeInTheDocument();
});
it("exposes variant BEM modifier", () => {
renderInterests({variant: "secondary"});
expect(screen.getByTestId("interests").className).toEqual(
expect.stringContaining("checkbox-group--secondary"),
);
});
it("calls onChange when selection is toggled via createTester", async () => {
const onChange = vi.fn();
renderInterests({onChange});
const tester = testUtilUser.createTester("CheckboxGroup", {
root: screen.getByTestId("interests"),
});
expect(tester.getCheckboxes()).toHaveLength(2);
await tester.toggleCheckbox({checkbox: "Coding"});
expect(onChange).toHaveBeenCalledWith(["coding"]);
expect(tester.getSelectedCheckboxes()).toHaveLength(1);
await tester.toggleCheckbox({checkbox: "Design"});
expect(onChange).toHaveBeenCalledWith(["coding", "design"]);
expect(tester.getSelectedCheckboxes()).toHaveLength(2);
});
it("supports keyboard toggle via createTester", async () => {
const onChange = vi.fn();
renderInterests({onChange});
const tester = testUtilUser.createTester("CheckboxGroup", {
root: screen.getByTestId("interests"),
interactionType: "keyboard",
});
await tester.toggleCheckbox({checkbox: "Design", interactionType: "keyboard"});
expect(onChange).toHaveBeenCalledWith(["design"]);
});
it("supports group disabled state", async () => {
const onChange = vi.fn();
renderInterests({isDisabled: true, onChange});
expect(screen.getByTestId("interests")).toHaveAttribute("data-disabled", "true");
expect(screen.getByRole("checkbox", {name: "Coding"})).toBeDisabled();
await user.click(screen.getByRole("checkbox", {name: "Coding"}));
expect(onChange).not.toHaveBeenCalled();
});
it("renders FieldError when invalid", () => {
renderInterests({isInvalid: true});
expect(screen.getByText("Pick at least one")).toBeInTheDocument();
expect(screen.getByTestId("interests")).toHaveAttribute("data-invalid", "true");
});
});
@@ -0,0 +1,22 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {Checkbox} from "@/components/checkbox";
import {Label} from "@/components/label";
describe("Checkbox SSR", () => {
it("renders without hydration mismatch when defaultSelected", async () => {
const {html} = await ssrSmoke(
<Checkbox defaultSelected>
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Label>Accept terms</Label>
</Checkbox.Content>
</Checkbox>,
);
expect(html).toContain('data-slot="checkbox"');
expect(html).toContain('data-selected="true"');
});
});
@@ -0,0 +1,163 @@
import type {ComponentProps} from "react";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {Checkbox} from "@/components/checkbox";
import {Label} from "@/components/label";
describe("Checkbox", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
const renderCheckbox = (props: ComponentProps<typeof Checkbox> = {}) =>
render(
<Checkbox {...props}>
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Label>Accept terms</Label>
</Checkbox.Content>
</Checkbox>,
);
it("renders with role and accessible name", () => {
renderCheckbox();
expect(screen.getByRole("checkbox", {name: "Accept terms"})).toBeInTheDocument();
});
it("exposes BEM block and composed data-slots", () => {
renderCheckbox();
const field = document.querySelector('[data-slot="checkbox"]');
expect(field?.className).toEqual(expect.stringContaining("checkbox"));
expect(document.querySelector('[data-slot="checkbox-content"]')).not.toBeNull();
expect(document.querySelector('[data-slot="checkbox-control"]')).not.toBeNull();
expect(document.querySelector('[data-slot="checkbox-indicator"]')).not.toBeNull();
});
it("supports data attribute passthrough on the field", () => {
render(
<Checkbox data-testid="terms">
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
Accept terms
</Checkbox.Content>
</Checkbox>,
);
expect(screen.getByTestId("terms")).toHaveAttribute("data-slot", "checkbox");
});
it("calls onChange when selection is toggled", async () => {
const onChange = vi.fn();
renderCheckbox({onChange});
const checkbox = screen.getByRole("checkbox", {name: "Accept terms"});
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(onChange).toHaveBeenCalledWith(true);
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
expect(onChange).toHaveBeenCalledWith(false);
});
it("supports controlled isSelected", async () => {
const onChange = vi.fn();
const {rerender} = render(
<Checkbox isSelected={false} onChange={onChange}>
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Label>Accept terms</Label>
</Checkbox.Content>
</Checkbox>,
);
const checkbox = screen.getByRole("checkbox", {name: "Accept terms"});
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(onChange).toHaveBeenCalledWith(true);
rerender(
<Checkbox isSelected onChange={onChange}>
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Label>Accept terms</Label>
</Checkbox.Content>
</Checkbox>,
);
expect(screen.getByRole("checkbox", {name: "Accept terms"})).toBeChecked();
});
it("exposes data-selected when selected", async () => {
renderCheckbox();
const checkbox = screen.getByRole("checkbox", {name: "Accept terms"});
const field = checkbox.closest("[data-slot='checkbox']");
await user.click(checkbox);
expect(field).toHaveAttribute("data-selected", "true");
});
it("supports indeterminate state", () => {
renderCheckbox({isIndeterminate: true});
const checkbox = screen.getByRole("checkbox", {name: "Accept terms"});
const field = checkbox.closest("[data-slot='checkbox']");
expect(checkbox).toHaveProperty("indeterminate", true);
expect(field).toHaveAttribute("data-indeterminate", "true");
});
it("supports Space key toggle", async () => {
const onChange = vi.fn();
renderCheckbox({onChange});
const checkbox = screen.getByRole("checkbox", {name: "Accept terms"});
await user.tab();
expect(checkbox).toHaveFocus();
await user.keyboard(" ");
expect(checkbox).toBeChecked();
expect(onChange).toHaveBeenCalledWith(true);
});
it("supports disabled state and blocks interaction", async () => {
const onChange = vi.fn();
renderCheckbox({isDisabled: true, onChange});
const checkbox = screen.getByRole("checkbox", {name: "Accept terms"});
const field = checkbox.closest("[data-slot='checkbox']");
expect(checkbox).toBeDisabled();
expect(field).toHaveAttribute("data-disabled", "true");
await user.click(checkbox);
expect(onChange).not.toHaveBeenCalled();
expect(checkbox).not.toBeChecked();
});
it("supports focus-visible via keyboard", async () => {
renderCheckbox();
const checkbox = screen.getByRole("checkbox", {name: "Accept terms"});
const content = checkbox.closest("[data-slot='checkbox-content']");
await user.tab();
expect(checkbox).toHaveFocus();
expect(content).toHaveAttribute("data-focus-visible", "true");
});
});
@@ -0,0 +1,52 @@
import {render, screen} from "@heroui/testing/helpers";
import {Chip} from "@/components/chip";
describe("Chip", () => {
it("renders text content", () => {
render(<Chip>New</Chip>);
expect(screen.getByText("New")).toBeInTheDocument();
});
it("exposes BEM block and data-slot", () => {
render(<Chip data-testid="status">Beta</Chip>);
const chip = screen.getByTestId("status");
expect(chip).toHaveAttribute("data-slot", "chip");
expect(chip.className).toEqual(expect.stringContaining("chip"));
});
it("exposes color BEM modifier", () => {
render(
<Chip color="accent" data-testid="chip">
Pro
</Chip>,
);
expect(screen.getByTestId("chip").className).toEqual(expect.stringContaining("chip--accent"));
});
it("supports data attribute passthrough", () => {
render(
<Chip data-foo="bar" data-testid="chip">
Tag
</Chip>,
);
expect(screen.getByTestId("chip")).toHaveAttribute("data-foo", "bar");
});
describe("Chip.Label", () => {
it("exposes data-slot when composed", () => {
render(
<Chip>
<Chip.Label>Pro</Chip.Label>
</Chip>,
);
expect(screen.getByText("Pro")).toBeInTheDocument();
expect(document.querySelector('[data-slot="chip-label"]')).not.toBeNull();
});
});
});
@@ -0,0 +1,114 @@
import {fireEvent, render, screen, setupUser} from "@heroui/testing/helpers";
import {CloseButton} from "@/components/close-button";
const pointerOpts = {
button: 0,
pointerId: 1,
pointerType: "mouse" as const,
clientX: 1,
clientY: 1,
};
// RAC usePress sequence in jsdom.
const pressDown = (element: HTMLElement) => fireEvent.pointerDown(element, pointerOpts);
const pressUp = (element: HTMLElement) => {
fireEvent.pointerUp(element, pointerOpts);
fireEvent.click(element, {button: 0});
};
describe("CloseButton", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("renders with default Close accessible name", () => {
render(<CloseButton />);
expect(screen.getByRole("button", {name: "Close"})).toBeInTheDocument();
});
it("exposes BEM block, data-slot, and default icon slot", () => {
render(<CloseButton />);
const button = screen.getByRole("button", {name: "Close"});
expect(button.className).toEqual(expect.stringContaining("close-button"));
expect(button).toHaveAttribute("data-slot", "close-button");
expect(button.querySelector('[data-slot="close-button-icon"]')).not.toBeNull();
});
it("supports overriding aria-label", () => {
render(<CloseButton aria-label="Dismiss dialog" />);
expect(screen.getByRole("button", {name: "Dismiss dialog"})).toBeInTheDocument();
});
it("supports data attribute passthrough", () => {
render(<CloseButton data-testid="close" />);
expect(screen.getByTestId("close")).toBeInTheDocument();
});
it("supports hover state", async () => {
render(<CloseButton />);
const button = screen.getByRole("button", {name: "Close"});
await user.hover(button);
expect(button).toHaveAttribute("data-hovered", "true");
await user.unhover(button);
expect(button).not.toHaveAttribute("data-hovered");
});
it("supports press state", () => {
render(<CloseButton />);
const button = screen.getByRole("button", {name: "Close"});
pressDown(button);
expect(button).toHaveAttribute("data-pressed", "true");
pressUp(button);
expect(button).not.toHaveAttribute("data-pressed");
});
it("calls onPress on click", async () => {
const onPress = vi.fn();
render(<CloseButton onPress={onPress} />);
await user.click(screen.getByRole("button", {name: "Close"}));
expect(onPress).toHaveBeenCalledTimes(1);
});
it("supports focus-visible via keyboard", async () => {
render(<CloseButton />);
const button = screen.getByRole("button", {name: "Close"});
await user.tab();
expect(button).toHaveFocus();
expect(button).toHaveAttribute("data-focus-visible", "true");
});
it("supports disabled state and blocks press", async () => {
const onPress = vi.fn();
render(<CloseButton isDisabled onPress={onPress} />);
const button = screen.getByRole("button", {name: "Close"});
expect(button).toBeDisabled();
expect(button).toHaveAttribute("data-disabled", "true");
await user.click(button);
expect(onPress).not.toHaveBeenCalled();
});
it("exposes no hover state when disabled", async () => {
render(<CloseButton isDisabled />);
const button = screen.getByRole("button", {name: "Close"});
await user.hover(button);
expect(button).not.toHaveAttribute("data-hovered");
});
});
@@ -0,0 +1,74 @@
import type {Color} from "@react-types/color";
import {act, render, screen, setupUser} from "@heroui/testing/helpers";
import {ColorArea} from "@/components/color-area";
const renderColorArea = (
props: {
defaultValue?: string;
onChange?: (color: Color) => void;
showDots?: boolean;
} = {},
) =>
render(
<ColorArea
aria-label="Color"
defaultValue={props.defaultValue ?? "hsl(200, 100%, 50%)"}
showDots={props.showDots}
onChange={props.onChange}
>
<ColorArea.Thumb />
</ColorArea>,
);
describe("ColorArea", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("renders an accessible 2D slider backed by two range inputs", () => {
renderColorArea();
// y-channel is aria-hidden; only x-channel is exposed.
expect(screen.getByRole("slider")).toBeInTheDocument();
expect(screen.getAllByRole("slider", {hidden: true})).toHaveLength(2);
});
it("exposes data-slot and BEM block", () => {
renderColorArea();
const root = document.querySelector('[data-slot="color-area"]');
expect(root?.className).toEqual(expect.stringContaining("color-area"));
expect(document.querySelector('[data-slot="color-area-thumb"]')).not.toBeNull();
});
it("exposes showDots BEM modifier", () => {
renderColorArea({showDots: true});
expect(document.querySelector('[data-slot="color-area"]')?.className).toEqual(
expect.stringContaining("color-area--show-dots"),
);
});
it("calls onChange with an updated color when adjusted via arrow keys", async () => {
const onChange = vi.fn();
renderColorArea({defaultValue: "hsl(200, 50%, 50%)", onChange});
const slider = screen.getByRole("slider");
await act(async () => {
slider.focus();
});
await user.keyboard("{ArrowRight}");
expect(onChange).toHaveBeenCalled();
const value = onChange.mock.calls.at(-1)?.[0] as Color;
expect(value.getChannelValue("hue")).toBeGreaterThan(200);
expect(value.toString("hsl")).not.toBe("hsl(200, 50%, 50%)");
});
});
@@ -0,0 +1,21 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {ColorField} from "@/components/color-field";
import {Label} from "@/components/label";
import {parseColor} from "@/components/rac";
describe("ColorField SSR", () => {
it("renders without hydration mismatch with a fixed color value", async () => {
const {html} = await ssrSmoke(
<ColorField defaultValue={parseColor("#0485F7")} name="color">
<Label>Color</Label>
<ColorField.Group>
<ColorField.Input />
</ColorField.Group>
</ColorField>,
);
expect(html).toContain('data-slot="color-field"');
expect(html).toContain("#0485F7");
});
});
@@ -0,0 +1,222 @@
import type {ComponentProps} from "react";
import type {Color} from "react-aria-components/ColorArea";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {ColorField} from "@/components/color-field";
import {Description} from "@/components/description";
import {FieldError} from "@/components/field-error";
import {Label} from "@/components/label";
import {parseColor} from "@/components/rac";
const renderColorField = (props: Partial<ComponentProps<typeof ColorField>> = {}) =>
render(
<ColorField name="color" {...props}>
<Label>Color</Label>
<ColorField.Group>
<ColorField.Input />
</ColorField.Group>
</ColorField>,
);
describe("ColorField", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("exposes accessible name via Label", () => {
renderColorField({defaultValue: parseColor("#0485F7")});
const input = screen.getByRole("textbox", {name: "Color"});
expect(input).toHaveValue("#0485F7");
});
it("exposes data-slots and BEM block", () => {
renderColorField();
expect(document.querySelector('[data-slot="color-field"]')?.className).toEqual(
expect.stringContaining("color-field"),
);
expect(document.querySelector('[data-slot="color-input-group"]')).not.toBeNull();
expect(document.querySelector('[data-slot="color-input-group-input"]')).not.toBeNull();
});
it("exposes variant BEM modifier on Group", () => {
render(
<ColorField name="color">
<Label>Color</Label>
<ColorField.Group variant="secondary">
<ColorField.Input />
</ColorField.Group>
</ColorField>,
);
expect(document.querySelector('[data-slot="color-input-group"]')?.className).toEqual(
expect.stringContaining("color-input-group--secondary"),
);
});
it("supports fullWidth prop as a BEM modifier", () => {
renderColorField({fullWidth: true});
expect(document.querySelector('[data-slot="color-field"]')?.className).toEqual(
expect.stringContaining("color-field--full-width"),
);
});
it("supports typing a hex value and commits it on blur", async () => {
const onChange = vi.fn();
renderColorField({onChange});
const input = screen.getByRole("textbox", {name: "Color"});
await user.type(input, "#22c55e");
await user.tab();
expect(onChange).toHaveBeenCalled();
const value = onChange.mock.calls.at(-1)?.[0] as Color;
expect(value.toString("hex").toLowerCase()).toBe("#22c55e");
});
it("supports controlled value", () => {
const onChange = vi.fn();
const {rerender} = render(
<ColorField name="color" value={parseColor("#0485F7")} onChange={onChange}>
<Label>Color</Label>
<ColorField.Group>
<ColorField.Input />
</ColorField.Group>
</ColorField>,
);
expect(screen.getByRole("textbox", {name: "Color"})).toHaveValue("#0485F7");
rerender(
<ColorField name="color" value={parseColor("#EF4444")} onChange={onChange}>
<Label>Color</Label>
<ColorField.Group>
<ColorField.Input />
</ColorField.Group>
</ColorField>,
);
expect(screen.getByRole("textbox", {name: "Color"})).toHaveValue("#EF4444");
});
it("supports channel editing for a single HSL channel", async () => {
const onChange = vi.fn();
render(
<ColorField
channel="hue"
colorSpace="hsl"
name="hue"
value={parseColor("hsl(200, 50%, 50%)")}
onChange={onChange}
>
<Label>Hue</Label>
<ColorField.Group>
<ColorField.Input />
</ColorField.Group>
</ColorField>,
);
const input = screen.getByRole("textbox", {name: "Hue"});
expect(input).toHaveValue("200°");
await user.clear(input);
await user.type(input, "90");
await user.tab();
expect(onChange).toHaveBeenCalled();
const value = onChange.mock.calls.at(-1)?.[0] as Color;
expect(value.getChannelValue("hue")).toBe(90);
});
it("supports isRequired and shows FieldError when isInvalid", () => {
render(
<ColorField isInvalid isRequired name="color">
<Label>Color</Label>
<ColorField.Group>
<ColorField.Input />
</ColorField.Group>
<FieldError>Please enter a valid hex color</FieldError>
</ColorField>,
);
expect(document.querySelector('[data-slot="color-field"]')).toHaveAttribute(
"data-required",
"true",
);
expect(screen.getByText("Please enter a valid hex color")).toBeInTheDocument();
});
it("exposes accessible description via Description", () => {
render(
<ColorField name="color">
<Label>Color</Label>
<ColorField.Group>
<ColorField.Input />
</ColorField.Group>
<Description>Enter your brand's primary color</Description>
</ColorField>,
);
expect(document.querySelector('[data-slot="description"]')).not.toBeNull();
expect(screen.getByText("Enter your brand's primary color")).toBeInTheDocument();
});
it("supports disabled state and blocks typing", async () => {
const onChange = vi.fn();
renderColorField({
defaultValue: parseColor("#0485F7"),
isDisabled: true,
onChange,
});
const input = screen.getByRole("textbox", {name: "Color"});
expect(input).toBeDisabled();
await user.type(input, "x");
expect(onChange).not.toHaveBeenCalled();
});
it("supports prefix and suffix composition data-slots", () => {
render(
<ColorField name="color">
<Label>Color</Label>
<ColorField.Group>
<ColorField.Prefix>
<span>prefix</span>
</ColorField.Prefix>
<ColorField.Input />
<ColorField.Suffix>
<span>suffix</span>
</ColorField.Suffix>
</ColorField.Group>
</ColorField>,
);
expect(document.querySelector('[data-slot="color-input-group-prefix"]')).not.toBeNull();
expect(document.querySelector('[data-slot="color-input-group-suffix"]')).not.toBeNull();
});
it("supports focus-visible via keyboard", async () => {
renderColorField();
await user.tab();
expect(screen.getByRole("textbox", {name: "Color"})).toHaveFocus();
const focusTarget = document.querySelector("[data-focus-visible='true']");
expect(focusTarget).not.toBeNull();
});
});
@@ -0,0 +1,39 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {ColorArea} from "@/components/color-area";
import {ColorPicker} from "@/components/color-picker";
import {ColorSlider} from "@/components/color-slider";
import {ColorSwatch} from "@/components/color-swatch";
import {Label} from "@/components/label";
describe("ColorPicker SSR", () => {
it("renders without hydration mismatch with a closed trigger and fixed defaultValue", async () => {
const {html} = await ssrSmoke(
<ColorPicker defaultValue="#0485F7">
<ColorPicker.Trigger>
<ColorSwatch size="lg" />
<Label>Pick a color</Label>
</ColorPicker.Trigger>
<ColorPicker.Popover>
<ColorArea
aria-label="Color area"
colorSpace="hsb"
xChannel="saturation"
yChannel="brightness"
>
<ColorArea.Thumb />
</ColorArea>
<ColorSlider channel="hue" colorSpace="hsb">
<Label>Hue</Label>
<ColorSlider.Track>
<ColorSlider.Thumb />
</ColorSlider.Track>
</ColorSlider>
</ColorPicker.Popover>
</ColorPicker>,
);
expect(html).toContain('data-slot="color-picker"');
expect(html).toContain('data-slot="color-picker-trigger"');
});
});
@@ -0,0 +1,117 @@
import type {Color} from "@react-types/color";
import {cleanup, render, runAllTimers, screen, setupUser} from "@heroui/testing/helpers";
import {ColorArea} from "@/components/color-area";
import {ColorPicker} from "@/components/color-picker";
import {ColorSlider} from "@/components/color-slider";
import {ColorSwatch} from "@/components/color-swatch";
import {ColorSwatchPicker} from "@/components/color-swatch-picker";
import {Label} from "@/components/label";
const presets = ["#ef4444", "#22c55e", "#3b82f6"];
const renderColorPicker = (
props: {
defaultValue?: string;
onChange?: (color: Color) => void;
withSwatches?: boolean;
} = {},
) => {
return render(
<ColorPicker defaultValue={props.defaultValue ?? "#0485F7"} onChange={props.onChange}>
<ColorPicker.Trigger>
<ColorSwatch size="lg" />
<Label>Pick a color</Label>
</ColorPicker.Trigger>
<ColorPicker.Popover>
{props.withSwatches ? (
<ColorSwatchPicker aria-label="Color presets" size="xs">
{presets.map((preset) => (
<ColorSwatchPicker.Item key={preset} color={preset}>
<ColorSwatchPicker.Swatch />
</ColorSwatchPicker.Item>
))}
</ColorSwatchPicker>
) : null}
<ColorArea
aria-label="Color area"
colorSpace="hsb"
xChannel="saturation"
yChannel="brightness"
>
<ColorArea.Thumb />
</ColorArea>
<ColorSlider channel="hue" colorSpace="hsb">
<Label>Hue</Label>
<ColorSlider.Output />
<ColorSlider.Track>
<ColorSlider.Thumb />
</ColorSlider.Track>
</ColorSlider>
</ColorPicker.Popover>
</ColorPicker>,
);
};
describe("ColorPicker", () => {
let user: ReturnType<typeof setupUser>;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
user = setupUser({advanceTimers: vi.advanceTimersByTime});
});
afterEach(() => {
cleanup();
runAllTimers();
vi.useRealTimers();
});
it("exposes data-slots and BEM block", () => {
renderColorPicker();
const root = document.querySelector('[data-slot="color-picker"]');
expect(root).not.toBeNull();
expect(root?.className).toEqual(expect.stringContaining("color-picker"));
expect(document.querySelector('[data-slot="color-picker-trigger"]')).not.toBeNull();
});
it("supports opening and closing the popover", async () => {
renderColorPicker();
await user.click(screen.getByRole("button", {name: /Pick a color/}));
runAllTimers();
expect(document.querySelector('[data-slot="color-picker-popover"]')).not.toBeNull();
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Hue")).toBeInTheDocument();
await user.keyboard("{Escape}");
runAllTimers();
expect(document.querySelector('[data-slot="color-picker-popover"]')).toBeNull();
});
it("supports updating color via ColorSwatchPicker item", async () => {
const onChange = vi.fn();
renderColorPicker({withSwatches: true, onChange});
await user.click(screen.getByRole("button", {name: /Pick a color/}));
runAllTimers();
const options = screen.getAllByRole("option");
expect(options.length).toBeGreaterThan(0);
await user.click(options[1]!);
runAllTimers();
expect(onChange).toHaveBeenCalled();
const color = onChange.mock.calls[0]?.[0];
expect(color.toString("hex").toLowerCase()).toBe("#22c55e");
});
});
@@ -0,0 +1,85 @@
import type {Color} from "@react-types/color";
import {act, render, screen, setupUser} from "@heroui/testing/helpers";
import {ColorSlider} from "@/components/color-slider";
import {Label} from "@/components/label";
const renderColorSlider = (
props: {
defaultValue?: string;
isDisabled?: boolean;
onChange?: (color: Color) => void;
} = {},
) =>
render(
<ColorSlider
channel="hue"
colorSpace="hsl"
defaultValue={props.defaultValue ?? "hsl(0, 100%, 50%)"}
isDisabled={props.isDisabled}
onChange={props.onChange}
>
<Label>Hue</Label>
<ColorSlider.Output />
<ColorSlider.Track>
<ColorSlider.Thumb />
</ColorSlider.Track>
</ColorSlider>,
);
describe("ColorSlider", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("renders a slider thumb with accessible name from composed Label", () => {
renderColorSlider();
expect(screen.getByRole("slider", {name: "Hue"})).toBeInTheDocument();
});
it("exposes data-slots and BEM block", () => {
renderColorSlider();
expect(document.querySelector('[data-slot="color-slider"]')?.className).toEqual(
expect.stringContaining("color-slider"),
);
expect(document.querySelector('[data-slot="color-slider-track"]')).not.toBeNull();
expect(document.querySelector('[data-slot="color-slider-thumb"]')).not.toBeNull();
expect(document.querySelector('[data-slot="color-slider-output"]')).not.toBeNull();
});
it("calls onChange with an updated hue when the channel is adjusted via arrow keys", async () => {
const onChange = vi.fn();
renderColorSlider({onChange});
const thumb = screen.getByRole("slider", {name: "Hue"});
await act(async () => {
thumb.focus();
});
await user.keyboard("{ArrowRight}");
expect(onChange).toHaveBeenCalled();
const value = onChange.mock.calls.at(-1)?.[0] as Color;
expect(value.getChannelValue("hue")).toBeGreaterThan(0);
});
it("supports disabled state and blocks keyboard interaction", async () => {
const onChange = vi.fn();
renderColorSlider({isDisabled: true, onChange});
const thumb = screen.getByRole("slider", {name: "Hue"});
await act(async () => {
thumb.focus();
});
await user.keyboard("{ArrowRight}");
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
import type {Color} from "@react-types/color";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {ColorSwatchPicker} from "@/components/color-swatch-picker";
const colors = ["#ef4444", "#22c55e", "#3b82f6"];
const renderPicker = (
props: {
defaultValue?: string;
onChange?: (color: Color) => void;
} = {},
) =>
render(
<ColorSwatchPicker
aria-label="Color presets"
defaultValue={props.defaultValue}
onChange={props.onChange}
>
{colors.map((color) => (
<ColorSwatchPicker.Item key={color} color={color}>
<ColorSwatchPicker.Swatch />
<ColorSwatchPicker.Indicator />
</ColorSwatchPicker.Item>
))}
</ColorSwatchPicker>,
);
describe("ColorSwatchPicker", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("renders a listbox of color options", () => {
renderPicker();
expect(screen.getByRole("listbox", {name: "Color presets"})).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(3);
});
it("exposes data-slots and BEM block", () => {
renderPicker();
expect(document.querySelector('[data-slot="color-swatch-picker"]')?.className).toEqual(
expect.stringContaining("color-swatch-picker"),
);
expect(document.querySelector('[data-slot="color-swatch-picker-item"]')).not.toBeNull();
expect(document.querySelector('[data-slot="color-swatch-picker-swatch"]')).not.toBeNull();
expect(document.querySelector('[data-slot="color-swatch-picker-indicator"]')).not.toBeNull();
});
it("exposes default layout BEM modifier", () => {
renderPicker();
expect(document.querySelector('[data-slot="color-swatch-picker"]')?.className).toEqual(
expect.stringContaining("color-swatch-picker--grid"),
);
});
it("calls onChange when selecting a swatch", async () => {
const onChange = vi.fn();
renderPicker({onChange});
const options = screen.getAllByRole("option");
await user.click(options[1]!);
expect(onChange).toHaveBeenCalled();
const color = onChange.mock.calls[0]?.[0] as Color;
expect(color.toString("hex").toLowerCase()).toBe("#22c55e");
});
});
@@ -0,0 +1,33 @@
import {render, screen} from "@heroui/testing/helpers";
import {ColorSwatch} from "@/components/color-swatch";
describe("ColorSwatch", () => {
it("renders with img role", () => {
render(<ColorSwatch color="#0485F7" />);
expect(screen.getByRole("img")).toBeInTheDocument();
});
it("exposes BEM block and data-slot", () => {
render(<ColorSwatch color="#0485F7" />);
const swatch = screen.getByRole("img");
expect(swatch).toHaveAttribute("data-slot", "color-swatch");
expect(swatch.className).toEqual(expect.stringContaining("color-swatch"));
});
it("exposes size and shape BEM modifiers", () => {
render(<ColorSwatch color="#0485F7" shape="square" size="lg" />);
const swatch = screen.getByRole("img");
expect(swatch.className).toEqual(expect.stringContaining("color-swatch--lg"));
expect(swatch.className).toEqual(expect.stringContaining("color-swatch--square"));
});
it("supports a custom accessible name via colorName", () => {
render(<ColorSwatch color="#0485F7" colorName="Brand blue" />);
expect(screen.getByRole("img", {name: "Brand blue"})).toBeInTheDocument();
});
});
@@ -0,0 +1,13 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {ComboBoxFixture} from "./fixtures";
describe("ComboBox SSR", () => {
it("renders without hydration mismatch when closed", async () => {
await ssrSmoke(<ComboBoxFixture />);
});
it("renders without hydration mismatch with a default value", async () => {
await ssrSmoke(<ComboBoxFixture defaultValue="cat" />);
});
});
@@ -0,0 +1,102 @@
import {User, cleanup, render, runAllTimers, screen, setupUser} from "@heroui/testing/helpers";
import {ComboBoxFixture} from "./fixtures";
describe("ComboBox", () => {
let user: ReturnType<typeof setupUser>;
let testUtilUser: User;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
user = setupUser({advanceTimers: vi.advanceTimersByTime});
testUtilUser = new User({
interactionType: "mouse",
advanceTimer: vi.advanceTimersByTime,
});
});
afterEach(() => {
cleanup();
runAllTimers();
vi.useRealTimers();
});
it("exposes data-slots and BEM block", () => {
render(<ComboBoxFixture />);
const root = screen.getByTestId("combo-box");
expect(root).toHaveAttribute("data-slot", "combo-box");
expect(root.className).toEqual(expect.stringContaining("combo-box"));
expect(document.querySelector('[data-slot="combo-box-input-group"]')).not.toBeNull();
expect(document.querySelector('[data-slot="combo-box-trigger"]')).not.toBeNull();
});
it("supports open and select via ComboBox tester", async () => {
const onChange = vi.fn();
render(<ComboBoxFixture onChange={onChange} />);
const tester = testUtilUser.createTester("ComboBox", {
root: screen.getByTestId("combo-box"),
});
await tester.open({triggerBehavior: "manual"});
runAllTimers();
expect(tester.getListbox()).not.toBeNull();
expect(document.querySelector('[data-slot="combo-box-popover"]')).not.toBeNull();
await tester.toggleOptionSelection({option: "Dog", triggerBehavior: "manual"});
runAllTimers();
expect(onChange).toHaveBeenCalledWith("dog");
expect(tester.getCombobox()).toHaveValue("Dog");
});
it("supports filtering options while typing", async () => {
render(<ComboBoxFixture />);
const combobox = screen.getByRole("combobox", {name: "Favorite Animal"});
await user.click(combobox);
await user.keyboard("pa");
runAllTimers();
expect(screen.getByRole("option", {name: "Panda"})).toBeInTheDocument();
expect(screen.queryByRole("option", {name: "Cat"})).toBeNull();
expect(screen.queryByRole("option", {name: "Dog"})).toBeNull();
});
it("supports disabled state", async () => {
const onChange = vi.fn();
render(<ComboBoxFixture isDisabled onChange={onChange} />);
const tester = testUtilUser.createTester("ComboBox", {
root: screen.getByTestId("combo-box"),
});
expect(tester.getCombobox()).toBeDisabled();
await tester.open({triggerBehavior: "manual"});
runAllTimers();
expect(tester.getListbox()).toBeNull();
expect(onChange).not.toHaveBeenCalled();
});
it("renders default selected value", () => {
render(<ComboBoxFixture defaultValue="cat" />);
expect(screen.getByRole("combobox", {name: "Favorite Animal"})).toHaveValue("Cat");
});
it("renders FieldError when invalid", () => {
render(<ComboBoxFixture isInvalid />);
expect(screen.getByText("Please choose an animal")).toBeInTheDocument();
expect(document.querySelector('[data-slot="field-error"]')).not.toBeNull();
expect(screen.getByTestId("combo-box")).toHaveAttribute("data-invalid", "true");
});
});
@@ -0,0 +1,47 @@
import type {Key} from "@react-types/shared";
import {ComboBox} from "@/components/combo-box";
import {FieldError} from "@/components/field-error";
import {Input} from "@/components/input";
import {Label} from "@/components/label";
import {ListBox} from "@/components/list-box";
export type ComboBoxFixtureProps = {
isDisabled?: boolean;
isInvalid?: boolean;
defaultValue?: string;
onChange?: (key: Key | null) => void;
};
export const ComboBoxFixture = (props: ComboBoxFixtureProps = {}) => (
<ComboBox
data-testid="combo-box"
defaultValue={props.defaultValue}
isDisabled={props.isDisabled}
isInvalid={props.isInvalid}
onChange={props.onChange}
>
<Label>Favorite Animal</Label>
<ComboBox.InputGroup>
<Input placeholder="Search animals..." />
<ComboBox.Trigger />
</ComboBox.InputGroup>
<ComboBox.Popover>
<ListBox>
<ListBox.Item id="cat" textValue="Cat">
Cat
<ListBox.ItemIndicator />
</ListBox.Item>
<ListBox.Item id="dog" textValue="Dog">
Dog
<ListBox.ItemIndicator />
</ListBox.Item>
<ListBox.Item id="panda" textValue="Panda">
Panda
<ListBox.ItemIndicator />
</ListBox.Item>
</ListBox>
</ComboBox.Popover>
{props.isInvalid ? <FieldError>Please choose an animal</FieldError> : null}
</ComboBox>
);
@@ -0,0 +1,50 @@
import type {ReactNode} from "react";
import {ssrSmoke} from "@heroui/testing/helpers";
import {CalendarDate} from "@internationalized/date";
import {DateField} from "@/components/date-field";
import {Description} from "@/components/description";
import {FieldError} from "@/components/field-error";
import {Label} from "@/components/label";
import {I18nProvider} from "@/components/rac";
const wrapper = ({children}: {children: ReactNode}) => (
<I18nProvider locale="en-US">{children}</I18nProvider>
);
describe("DateField SSR", () => {
it("renders and hydrates a controlled value without mismatches", async () => {
const {html} = await ssrSmoke(
<DateField name="date" value={new CalendarDate(2026, 8, 15)}>
<Label>Date</Label>
<DateField.Group>
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
</DateField.Group>
<Description>Enter a date</Description>
</DateField>,
{wrapper},
);
expect(html).toContain('data-slot="date-field"');
expect(html).toContain('data-slot="date-input-group"');
expect(html).toContain('role="spinbutton"');
expect(html).toContain("2026");
});
it("renders and hydrates an invalid, required field with FieldError", async () => {
const {html} = await ssrSmoke(
<DateField isInvalid isRequired name="date" value={new CalendarDate(2026, 8, 15)}>
<Label>Date</Label>
<DateField.Group>
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
</DateField.Group>
<FieldError>Please enter a valid date</FieldError>
</DateField>,
{wrapper},
);
expect(html).toContain('data-required="true"');
expect(html).toContain("Please enter a valid date");
});
});
@@ -0,0 +1,221 @@
import type {DateValue} from "@internationalized/date";
import type {ComponentProps} from "react";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {CalendarDate} from "@internationalized/date";
import {DateField} from "@/components/date-field";
import {Description} from "@/components/description";
import {FieldError} from "@/components/field-error";
import {Label} from "@/components/label";
const renderDateField = (props: Partial<ComponentProps<typeof DateField>> = {}) =>
render(
<DateField name="date" {...props}>
<Label>Date</Label>
<DateField.Group>
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
</DateField.Group>
</DateField>,
);
describe("DateField", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("renders segmented spinbuttons for month, day, and year", () => {
renderDateField({defaultValue: new CalendarDate(2026, 8, 15)});
const segments = screen.getAllByRole("spinbutton");
expect(segments).toHaveLength(3);
expect(screen.getByRole("spinbutton", {name: /month/i})).toHaveTextContent("8");
expect(screen.getByRole("spinbutton", {name: /day/i})).toHaveTextContent("15");
expect(screen.getByRole("spinbutton", {name: /year/i})).toHaveTextContent("2026");
});
it("exposes data-slots and BEM block", () => {
renderDateField();
expect(document.querySelector('[data-slot="date-field"]')?.className).toEqual(
expect.stringContaining("date-field"),
);
expect(document.querySelector('[data-slot="date-input-group"]')).not.toBeNull();
expect(document.querySelector('[data-slot="date-input-group-input"]')).not.toBeNull();
expect(
document.querySelectorAll('[data-slot="date-input-group-segment"][role="spinbutton"]').length,
).toBe(3);
});
it("exposes variant BEM modifier on Group", () => {
render(
<DateField name="date">
<Label>Date</Label>
<DateField.Group variant="secondary">
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
</DateField.Group>
</DateField>,
);
expect(document.querySelector('[data-slot="date-input-group"]')?.className).toEqual(
expect.stringContaining("date-input-group--secondary"),
);
});
it("supports fullWidth prop as a BEM modifier", () => {
renderDateField({fullWidth: true});
expect(document.querySelector('[data-slot="date-field"]')?.className).toEqual(
expect.stringContaining("date-field--full-width"),
);
});
it("calls onChange when a segment is incremented or decremented", async () => {
const onChange = vi.fn();
renderDateField({defaultValue: new CalendarDate(2026, 8, 15), onChange});
const day = screen.getByRole("spinbutton", {name: /day/i});
await user.click(day);
await user.keyboard("{ArrowUp}");
expect(onChange).toHaveBeenCalled();
let value = onChange.mock.calls.at(-1)?.[0] as DateValue;
expect(value.toString()).toBe("2026-08-16");
await user.keyboard("{ArrowDown}");
value = onChange.mock.calls.at(-1)?.[0] as DateValue;
expect(value.toString()).toBe("2026-08-15");
});
it("supports typing digits into segments to build a full date", async () => {
const onChange = vi.fn();
renderDateField({onChange});
const month = screen.getByRole("spinbutton", {name: /month/i});
await user.click(month);
await user.keyboard("01022026");
expect(onChange).toHaveBeenCalled();
const value = onChange.mock.calls.at(-1)?.[0] as DateValue;
expect(value.toString()).toBe("2026-01-02");
});
it("supports controlled value", () => {
const onChange = vi.fn();
const {rerender} = render(
<DateField name="date" value={new CalendarDate(2026, 8, 15)} onChange={onChange}>
<Label>Date</Label>
<DateField.Group>
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
</DateField.Group>
</DateField>,
);
expect(screen.getByRole("spinbutton", {name: /year/i})).toHaveTextContent("2026");
rerender(
<DateField name="date" value={new CalendarDate(2027, 1, 1)} onChange={onChange}>
<Label>Date</Label>
<DateField.Group>
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
</DateField.Group>
</DateField>,
);
expect(screen.getByRole("spinbutton", {name: /year/i})).toHaveTextContent("2027");
expect(screen.getByRole("spinbutton", {name: /month/i})).toHaveTextContent("1");
});
it("supports isRequired and shows FieldError when isInvalid", () => {
render(
<DateField isInvalid isRequired name="date">
<Label>Date</Label>
<DateField.Group>
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
</DateField.Group>
<FieldError>Please enter a valid date</FieldError>
</DateField>,
);
expect(document.querySelector('[data-slot="date-field"]')).toHaveAttribute(
"data-required",
"true",
);
expect(screen.getByText("Please enter a valid date")).toBeInTheDocument();
});
it("exposes accessible description via Description", () => {
render(
<DateField name="date">
<Label>Date</Label>
<DateField.Group>
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
</DateField.Group>
<Description>Enter a date</Description>
</DateField>,
);
expect(document.querySelector('[data-slot="description"]')).not.toBeNull();
expect(screen.getByText("Enter a date")).toBeInTheDocument();
});
it("supports disabled state and blocks value changes", async () => {
const onChange = vi.fn();
renderDateField({
defaultValue: new CalendarDate(2026, 8, 15),
isDisabled: true,
onChange,
});
const day = screen.getByRole("spinbutton", {name: /day/i});
await user.click(day);
await user.keyboard("{ArrowUp}");
expect(onChange).not.toHaveBeenCalled();
});
it("supports prefix and suffix composition data-slots", () => {
render(
<DateField name="date">
<Label>Date</Label>
<DateField.Group>
<DateField.Prefix>
<span>prefix</span>
</DateField.Prefix>
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
<DateField.Suffix>
<span>suffix</span>
</DateField.Suffix>
</DateField.Group>
</DateField>,
);
expect(document.querySelector('[data-slot="date-input-group-prefix"]')).not.toBeNull();
expect(document.querySelector('[data-slot="date-input-group-suffix"]')).not.toBeNull();
});
it("supports focus-visible via keyboard", async () => {
renderDateField({defaultValue: new CalendarDate(2026, 8, 15)});
await user.tab();
const month = screen.getByRole("spinbutton", {name: /month/i});
expect(month).toHaveFocus();
const focusTarget = document.querySelector("[data-focus-visible='true']");
expect(focusTarget).not.toBeNull();
});
});
@@ -0,0 +1,65 @@
import type {ReactNode} from "react";
import {ssrSmoke} from "@heroui/testing/helpers";
import {CalendarDate} from "@internationalized/date";
import {Calendar} from "@/components/calendar";
import {DateField} from "@/components/date-field";
import {DatePicker} from "@/components/date-picker";
import {Label} from "@/components/label";
import {I18nProvider} from "@/components/rac";
const wrapper = ({children}: {children: ReactNode}) => (
<I18nProvider locale="en-US">{children}</I18nProvider>
);
const DatePickerExample = () => (
<DatePicker data-testid="date-picker" name="date" value={new CalendarDate(2026, 8, 15)}>
<Label>Date</Label>
<DateField.Group>
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
<DateField.Suffix>
<DatePicker.Trigger aria-label="Open calendar">
<DatePicker.TriggerIndicator />
</DatePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DatePicker.Popover>
<Calendar aria-label="Selected date">
<Calendar.Header>
<Calendar.YearPickerTrigger>
<Calendar.YearPickerTriggerHeading />
<Calendar.YearPickerTriggerIndicator />
</Calendar.YearPickerTrigger>
<Calendar.NavButton slot="previous" />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>
{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}
</Calendar.GridHeader>
<Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody>
</Calendar.Grid>
</Calendar>
</DatePicker.Popover>
</DatePicker>
);
describe("DatePicker SSR", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2026-08-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("renders and hydrates the closed trigger without mismatches", async () => {
const {html} = await ssrSmoke(<DatePickerExample />, {wrapper});
expect(html).toContain('data-slot="date-picker"');
expect(html).toContain('data-slot="date-picker-trigger"');
expect(html).toContain('role="spinbutton"');
expect(html).toContain("2026");
});
});
@@ -0,0 +1,122 @@
import type {DateValue} from "@internationalized/date";
import {cleanup, render, runAllTimers, screen, setupUser} from "@heroui/testing/helpers";
import {CalendarDate} from "@internationalized/date";
import {Calendar} from "@/components/calendar";
import {DateField} from "@/components/date-field";
import {DatePicker} from "@/components/date-picker";
import {Label} from "@/components/label";
const renderDatePicker = (
props: {
value?: DateValue | null;
defaultValue?: DateValue;
onChange?: (value: DateValue | null) => void;
} = {},
) => {
return render(
<DatePicker
data-testid="date-picker"
defaultValue={props.defaultValue}
name="date"
value={props.value}
onChange={props.onChange}
>
<Label>Date</Label>
<DateField.Group>
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
<DateField.Suffix>
<DatePicker.Trigger aria-label="Open calendar">
<DatePicker.TriggerIndicator />
</DatePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DatePicker.Popover>
<Calendar aria-label="Selected date">
<Calendar.Header>
<Calendar.YearPickerTrigger>
<Calendar.YearPickerTriggerHeading />
<Calendar.YearPickerTriggerIndicator />
</Calendar.YearPickerTrigger>
<Calendar.NavButton slot="previous" />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>
{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}
</Calendar.GridHeader>
<Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody>
</Calendar.Grid>
</Calendar>
</DatePicker.Popover>
</DatePicker>,
);
};
describe("DatePicker", () => {
let user: ReturnType<typeof setupUser>;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
user = setupUser({advanceTimers: vi.advanceTimersByTime});
});
afterEach(() => {
cleanup();
runAllTimers();
vi.useRealTimers();
});
it("exposes data-slots and BEM block", () => {
renderDatePicker();
const root = screen.getByTestId("date-picker");
expect(root).toHaveAttribute("data-slot", "date-picker");
expect(root.className).toEqual(expect.stringContaining("date-picker"));
expect(document.querySelector('[data-slot="date-picker-trigger"]')).not.toBeNull();
});
it("supports opening and closing the calendar popover", async () => {
renderDatePicker();
await user.click(screen.getByRole("button", {name: /Open calendar/}));
runAllTimers();
expect(document.querySelector('[data-slot="date-picker-popover"]')).not.toBeNull();
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("button", {name: "Previous"})).toBeInTheDocument();
await user.keyboard("{Escape}");
runAllTimers();
expect(document.querySelector('[data-slot="date-picker-popover"]')).toBeNull();
});
it("calls onChange when a calendar date is selected", async () => {
const onChange = vi.fn();
renderDatePicker({
defaultValue: new CalendarDate(2026, 8, 1),
onChange,
});
await user.click(screen.getByRole("button", {name: /Open calendar/}));
runAllTimers();
const cell = screen.getByRole("button", {
name: (accessibleName) => accessibleName.includes("15") && accessibleName.includes("August"),
});
await user.click(cell);
runAllTimers();
expect(onChange).toHaveBeenCalled();
const value = onChange.mock.calls[0]?.[0] as CalendarDate;
expect(value.year).toBe(2026);
expect(value.month).toBe(8);
expect(value.day).toBe(15);
});
});
@@ -0,0 +1,78 @@
import type {ReactNode} from "react";
import {ssrSmoke} from "@heroui/testing/helpers";
import {CalendarDate} from "@internationalized/date";
import {DateField} from "@/components/date-field";
import {DateRangePicker} from "@/components/date-range-picker";
import {Label} from "@/components/label";
import {I18nProvider} from "@/components/rac";
import {RangeCalendar} from "@/components/range-calendar";
const wrapper = ({children}: {children: ReactNode}) => (
<I18nProvider locale="en-US">{children}</I18nProvider>
);
const DateRangePickerExample = () => (
<DateRangePicker
data-testid="date-range-picker"
endName="endDate"
startName="startDate"
value={{start: new CalendarDate(2026, 8, 1), end: new CalendarDate(2026, 8, 5)}}
>
<Label>Trip dates</Label>
<DateField.Group>
<DateField.Input slot="start">
{(segment) => <DateField.Segment segment={segment} />}
</DateField.Input>
<DateRangePicker.RangeSeparator />
<DateField.Input slot="end">
{(segment) => <DateField.Segment segment={segment} />}
</DateField.Input>
<DateField.Suffix>
<DateRangePicker.Trigger aria-label="Open calendar">
<DateRangePicker.TriggerIndicator />
</DateRangePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DateRangePicker.Popover>
<RangeCalendar aria-label="Selected range">
<RangeCalendar.Header>
<RangeCalendar.YearPickerTrigger>
<RangeCalendar.YearPickerTriggerHeading />
<RangeCalendar.YearPickerTriggerIndicator />
</RangeCalendar.YearPickerTrigger>
<RangeCalendar.NavButton slot="previous" />
<RangeCalendar.NavButton slot="next" />
</RangeCalendar.Header>
<RangeCalendar.Grid>
<RangeCalendar.GridHeader>
{(day) => <RangeCalendar.HeaderCell>{day}</RangeCalendar.HeaderCell>}
</RangeCalendar.GridHeader>
<RangeCalendar.GridBody>
{(date) => <RangeCalendar.Cell date={date} />}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
</RangeCalendar>
</DateRangePicker.Popover>
</DateRangePicker>
);
describe("DateRangePicker SSR", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2026-08-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("renders and hydrates the closed trigger without mismatches", async () => {
const {html} = await ssrSmoke(<DateRangePickerExample />, {wrapper});
expect(html).toContain('data-slot="date-range-picker"');
expect(html).toContain('data-slot="date-range-picker-trigger"');
expect(html).toContain('data-slot="date-range-picker-range-separator"');
expect(html).toContain('role="spinbutton"');
});
});
@@ -0,0 +1,144 @@
import type {DateValue} from "@internationalized/date";
import {cleanup, render, runAllTimers, screen, setupUser} from "@heroui/testing/helpers";
import {CalendarDate} from "@internationalized/date";
import {DateField} from "@/components/date-field";
import {DateRangePicker} from "@/components/date-range-picker";
import {Label} from "@/components/label";
import {RangeCalendar} from "@/components/range-calendar";
type DateRange = {
start: DateValue;
end: DateValue;
};
const renderDateRangePicker = (
props: {
defaultValue?: DateRange;
onChange?: (value: DateRange | null) => void;
} = {},
) => {
return render(
<DateRangePicker
data-testid="date-range-picker"
defaultValue={props.defaultValue}
endName="endDate"
startName="startDate"
onChange={props.onChange}
>
<Label>Trip dates</Label>
<DateField.Group>
<DateField.Input slot="start">
{(segment) => <DateField.Segment segment={segment} />}
</DateField.Input>
<DateRangePicker.RangeSeparator />
<DateField.Input slot="end">
{(segment) => <DateField.Segment segment={segment} />}
</DateField.Input>
<DateField.Suffix>
<DateRangePicker.Trigger aria-label="Open calendar">
<DateRangePicker.TriggerIndicator />
</DateRangePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DateRangePicker.Popover>
<RangeCalendar aria-label="Selected range">
<RangeCalendar.Header>
<RangeCalendar.YearPickerTrigger>
<RangeCalendar.YearPickerTriggerHeading />
<RangeCalendar.YearPickerTriggerIndicator />
</RangeCalendar.YearPickerTrigger>
<RangeCalendar.NavButton slot="previous" />
<RangeCalendar.NavButton slot="next" />
</RangeCalendar.Header>
<RangeCalendar.Grid>
<RangeCalendar.GridHeader>
{(day) => <RangeCalendar.HeaderCell>{day}</RangeCalendar.HeaderCell>}
</RangeCalendar.GridHeader>
<RangeCalendar.GridBody>
{(date) => <RangeCalendar.Cell date={date} />}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
</RangeCalendar>
</DateRangePicker.Popover>
</DateRangePicker>,
);
};
describe("DateRangePicker", () => {
let user: ReturnType<typeof setupUser>;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
user = setupUser({advanceTimers: vi.advanceTimersByTime});
});
afterEach(() => {
cleanup();
runAllTimers();
vi.useRealTimers();
});
it("exposes data-slots and BEM block", () => {
renderDateRangePicker();
const root = screen.getByTestId("date-range-picker");
expect(root).toHaveAttribute("data-slot", "date-range-picker");
expect(root.className).toEqual(expect.stringContaining("date-range-picker"));
expect(document.querySelector('[data-slot="date-range-picker-trigger"]')).not.toBeNull();
expect(
document.querySelector('[data-slot="date-range-picker-range-separator"]'),
).not.toBeNull();
});
it("supports opening and closing the range calendar popover", async () => {
renderDateRangePicker();
await user.click(screen.getByRole("button", {name: /Open calendar/}));
runAllTimers();
expect(document.querySelector('[data-slot="date-range-picker-popover"]')).not.toBeNull();
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("button", {name: "Previous"})).toBeInTheDocument();
await user.keyboard("{Escape}");
runAllTimers();
expect(document.querySelector('[data-slot="date-range-picker-popover"]')).toBeNull();
});
it("calls onChange when a date range is selected", async () => {
const onChange = vi.fn();
renderDateRangePicker({
defaultValue: {
start: new CalendarDate(2026, 8, 1),
end: new CalendarDate(2026, 8, 5),
},
onChange,
});
await user.click(screen.getByRole("button", {name: /Open calendar/}));
runAllTimers();
const startCell = screen.getByRole("button", {
name: (accessibleName) => accessibleName.includes("10") && accessibleName.includes("August"),
});
const endCell = screen.getByRole("button", {
name: (accessibleName) => accessibleName.includes("12") && accessibleName.includes("August"),
});
await user.click(startCell);
runAllTimers();
await user.click(endCell);
runAllTimers();
expect(onChange).toHaveBeenCalled();
const value = onChange.mock.calls.at(-1)?.[0] as DateRange;
expect(value.start.toString()).toBe("2026-08-10");
expect(value.end.toString()).toBe("2026-08-12");
});
});
@@ -0,0 +1,45 @@
import {render, screen} from "@heroui/testing/helpers";
import {Description} from "@/components/description";
import {Input} from "@/components/input";
import {Label} from "@/components/label";
import {TextField} from "@/components/textfield";
describe("Description", () => {
it("exposes data-slot and BEM block inside TextField", () => {
render(
<TextField name="name">
<Label>Your name</Label>
<Input />
<Description>Visible to other users</Description>
</TextField>,
);
const description = document.querySelector('[data-slot="description"]');
expect(description).not.toBeNull();
expect(description?.className).toEqual(expect.stringContaining("description"));
expect(screen.getByText("Visible to other users")).toBeInTheDocument();
});
it("exposes accessible description on the input", () => {
render(
<TextField name="name">
<Label>Your name</Label>
<Input />
<Description>Helpful hint</Description>
</TextField>,
);
expect(screen.getByRole("textbox", {name: "Your name"})).toHaveAccessibleDescription(
"Helpful hint",
);
});
it("renders standalone outside a field gate", () => {
render(<Description>Standalone</Description>);
expect(document.querySelector('[data-slot="description"]')).not.toBeNull();
expect(screen.getByText("Standalone")).toBeInTheDocument();
});
});
@@ -0,0 +1,38 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {Disclosure} from "@/components/disclosure";
import {DisclosureGroup} from "@/components/disclosure-group";
describe("DisclosureGroup SSR", () => {
it("renders without hydration mismatch with one expanded item", async () => {
const {html} = await ssrSmoke(
<DisclosureGroup data-testid="disclosure-group" defaultExpandedKeys={["preview"]}>
<Disclosure aria-label="Preview" id="preview">
<Disclosure.Heading>
<Disclosure.Trigger>
Preview
<Disclosure.Indicator />
</Disclosure.Trigger>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>Preview content</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
<Disclosure aria-label="Download" id="download">
<Disclosure.Heading>
<Disclosure.Trigger>
Download
<Disclosure.Indicator />
</Disclosure.Trigger>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>Download content</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</DisclosureGroup>,
);
expect(html).toContain('data-slot="disclosure-group"');
expect(html).toContain('aria-expanded="true"');
});
});
@@ -0,0 +1,104 @@
import type {ComponentProps} from "react";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {Disclosure} from "@/components/disclosure";
import {DisclosureGroup} from "@/components/disclosure-group";
const renderGroup = (props: ComponentProps<typeof DisclosureGroup> = {}) =>
render(
<DisclosureGroup data-testid="disclosure-group" {...props}>
<Disclosure aria-label="Preview" id="preview">
<Disclosure.Heading>
<Disclosure.Trigger>
Preview
<Disclosure.Indicator />
</Disclosure.Trigger>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>Preview content</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
<Disclosure aria-label="Download" id="download">
<Disclosure.Heading>
<Disclosure.Trigger>
Download
<Disclosure.Indicator />
</Disclosure.Trigger>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>Download content</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</DisclosureGroup>,
);
describe("DisclosureGroup", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("exposes data-slot and BEM block", () => {
renderGroup();
const group = screen.getByTestId("disclosure-group");
expect(group).toHaveAttribute("data-slot", "disclosure-group");
expect(group.className).toEqual(expect.stringContaining("disclosure-group"));
});
it("renders each Disclosure trigger", () => {
renderGroup();
expect(screen.getByRole("button", {name: "Preview"})).toBeInTheDocument();
expect(screen.getByRole("button", {name: "Download"})).toBeInTheDocument();
});
it("supports single expanded item by default", async () => {
renderGroup();
const preview = screen.getByRole("button", {name: "Preview"});
const download = screen.getByRole("button", {name: "Download"});
await user.click(preview);
expect(preview).toHaveAttribute("aria-expanded", "true");
await user.click(download);
expect(download).toHaveAttribute("aria-expanded", "true");
expect(preview).toHaveAttribute("aria-expanded", "false");
});
it("supports multiple expanded items with allowsMultipleExpanded", async () => {
renderGroup({allowsMultipleExpanded: true});
const preview = screen.getByRole("button", {name: "Preview"});
const download = screen.getByRole("button", {name: "Download"});
await user.click(preview);
await user.click(download);
expect(preview).toHaveAttribute("aria-expanded", "true");
expect(download).toHaveAttribute("aria-expanded", "true");
});
it("supports controlled expandedKeys + onExpandedChange", async () => {
const onExpandedChange = vi.fn();
renderGroup({expandedKeys: new Set(["preview"]), onExpandedChange});
expect(screen.getByRole("button", {name: "Preview"})).toHaveAttribute("aria-expanded", "true");
await user.click(screen.getByRole("button", {name: "Download"}));
expect(onExpandedChange).toHaveBeenCalledWith(new Set(["download"]));
});
it("supports group disabled state on all items", () => {
renderGroup({isDisabled: true});
expect(screen.getByRole("button", {name: "Preview"})).toBeDisabled();
expect(screen.getByRole("button", {name: "Download"})).toBeDisabled();
});
});
@@ -0,0 +1,24 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {Disclosure} from "@/components/disclosure";
describe("Disclosure SSR", () => {
it("renders without hydration mismatch when defaultExpanded", async () => {
const {html} = await ssrSmoke(
<Disclosure defaultExpanded data-testid="disclosure">
<Disclosure.Heading>
<Disclosure.Trigger>
Toggle content
<Disclosure.Indicator />
</Disclosure.Trigger>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>Hidden content revealed on expand.</Disclosure.Body>
</Disclosure.Content>
</Disclosure>,
);
expect(html).toContain('data-slot="disclosure"');
expect(html).toContain('aria-expanded="true"');
});
});
@@ -0,0 +1,121 @@
import type {ComponentProps} from "react";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {Disclosure} from "@/components/disclosure";
const renderDisclosure = (props: ComponentProps<typeof Disclosure> = {}) =>
render(
<Disclosure data-testid="disclosure" {...props}>
<Disclosure.Heading>
<Disclosure.Trigger>
Toggle content
<Disclosure.Indicator />
</Disclosure.Trigger>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>Hidden content revealed on expand.</Disclosure.Body>
</Disclosure.Content>
</Disclosure>,
);
describe("Disclosure", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("exposes data-slot, BEM block, and compound part slots", () => {
renderDisclosure();
const root = screen.getByTestId("disclosure");
expect(root).toHaveAttribute("data-slot", "disclosure");
expect(root.className).toEqual(expect.stringContaining("disclosure"));
expect(document.querySelector('[data-slot="disclosure-heading"]')).not.toBeNull();
expect(document.querySelector('[data-slot="disclosure-trigger"]')).not.toBeNull();
expect(document.querySelector('[data-slot="disclosure-content"]')).not.toBeNull();
expect(document.querySelector('[data-slot="disclosure-indicator"]')).not.toBeNull();
});
it("supports expanding via the trigger", async () => {
renderDisclosure();
const trigger = screen.getByRole("button", {name: "Toggle content"});
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("Hidden content revealed on expand.")).not.toBeVisible();
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Hidden content revealed on expand.")).toBeVisible();
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("Hidden content revealed on expand.")).not.toBeVisible();
});
it("supports expanding via keyboard (Enter/Space)", async () => {
renderDisclosure();
const trigger = screen.getByRole("button", {name: "Toggle content"});
await user.tab();
expect(trigger).toHaveFocus();
expect(trigger).toHaveAttribute("aria-expanded", "false");
await user.keyboard("{Enter}");
expect(trigger).toHaveAttribute("aria-expanded", "true");
await user.keyboard(" ");
expect(trigger).toHaveAttribute("aria-expanded", "false");
});
it("supports defaultExpanded", () => {
renderDisclosure({defaultExpanded: true});
expect(screen.getByRole("button", {name: "Toggle content"})).toHaveAttribute(
"aria-expanded",
"true",
);
expect(screen.getByText("Hidden content revealed on expand.")).toBeInTheDocument();
});
it("supports controlled isExpanded + onExpandedChange", async () => {
const onExpandedChange = vi.fn();
renderDisclosure({isExpanded: false, onExpandedChange});
await user.click(screen.getByRole("button", {name: "Toggle content"}));
expect(onExpandedChange).toHaveBeenCalledWith(true);
expect(screen.getByText("Hidden content revealed on expand.")).not.toBeVisible();
});
it("supports disabled state without toggling", async () => {
renderDisclosure({isDisabled: true});
const trigger = screen.getByRole("button", {name: "Toggle content"});
expect(trigger).toBeDisabled();
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "false");
});
it("exposes data-expanded on the indicator", async () => {
renderDisclosure();
const indicator = document.querySelector('[data-slot="disclosure-indicator"]');
expect(indicator).not.toHaveAttribute("data-expanded");
await user.click(screen.getByRole("button", {name: "Toggle content"}));
expect(indicator).toHaveAttribute("data-expanded", "true");
});
});
@@ -0,0 +1,32 @@
import {render} from "@heroui/testing/browser";
import {isDocumentScrollLocked} from "@heroui/testing/helpers";
import {page, userEvent} from "vitest/browser";
import {DrawerFixture} from "./fixtures";
const renderDrawer = () => render(<DrawerFixture />);
describe("Drawer (browser)", () => {
it("supports focus trap, scroll lock, and Escape focus restore", async () => {
await renderDrawer();
const trigger = page.getByRole("button", {name: "Open Drawer"});
await trigger.click();
const dialog = page.getByRole("dialog");
await expect.element(dialog).toBeInTheDocument();
expect(isDocumentScrollLocked()).toBe(true);
expect(dialog.element().contains(document.activeElement)).toBe(true);
await userEvent.tab();
expect(dialog.element().contains(document.activeElement)).toBe(true);
await userEvent.keyboard("{Escape}");
await expect.element(dialog).not.toBeInTheDocument();
expect(isDocumentScrollLocked()).toBe(false);
await expect.element(trigger).toHaveFocus();
});
});
@@ -0,0 +1,13 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {DrawerFixture} from "./fixtures";
describe("Drawer SSR", () => {
it("renders without hydration mismatch when closed", async () => {
await ssrSmoke(<DrawerFixture />);
});
it("renders without hydration mismatch when defaultOpen", async () => {
await ssrSmoke(<DrawerFixture defaultOpen />);
});
});
@@ -0,0 +1,141 @@
import {User, cleanup, render, runAllTimers, screen, setupUser} from "@heroui/testing/helpers";
import {DrawerFixture} from "./fixtures";
const renderDrawer = (
props: {
defaultOpen?: boolean;
isOpen?: boolean;
isDismissable?: boolean;
isKeyboardDismissDisabled?: boolean;
onOpenChange?: (open: boolean) => void;
placement?: "top" | "bottom" | "left" | "right";
} = {},
) => render(<DrawerFixture {...props} />);
describe("Drawer", () => {
let user: ReturnType<typeof setupUser>;
let testUtilUser: User;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
user = setupUser({advanceTimers: vi.advanceTimersByTime});
testUtilUser = new User({
interactionType: "mouse",
advanceTimer: vi.advanceTimersByTime,
});
});
afterEach(() => {
cleanup();
runAllTimers();
vi.useRealTimers();
});
it("exposes compound slots via Dialog tester", async () => {
renderDrawer();
const tester = testUtilUser.createTester("Dialog", {
root: screen.getByRole("button", {name: "Open Drawer"}),
overlayType: "modal",
});
expect(tester.getDialog()).toBeNull();
await tester.open();
runAllTimers();
const dialog = tester.getDialog();
expect(dialog).not.toBeNull();
expect(dialog).toHaveAttribute("data-slot", "drawer-dialog");
expect(dialog?.className).toEqual(expect.stringContaining("drawer__dialog"));
expect(document.querySelector('[data-slot="drawer-backdrop"]')).not.toBeNull();
expect(document.querySelector('[data-slot="drawer-content"]')).not.toBeNull();
expect(document.querySelector('[data-slot="drawer-handle"]')).not.toBeNull();
expect(document.querySelector('[data-slot="drawer-header"]')).not.toBeNull();
expect(document.querySelector('[data-slot="drawer-body"]')).not.toBeNull();
expect(document.querySelector('[data-slot="drawer-footer"]')).not.toBeNull();
expect(screen.getByRole("heading", {name: "Drawer Title"})).toBeInTheDocument();
await tester.close();
runAllTimers();
expect(tester.getDialog()).toBeNull();
});
it("supports Escape dismiss when dismissable", async () => {
const onOpenChange = vi.fn();
renderDrawer({defaultOpen: true, onOpenChange});
runAllTimers();
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
runAllTimers();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("blocks Escape dismiss when keyboard dismiss is disabled", async () => {
const onOpenChange = vi.fn();
renderDrawer({defaultOpen: true, isKeyboardDismissDisabled: true, onOpenChange});
runAllTimers();
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
runAllTimers();
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it("supports closing via CloseTrigger when outside dismiss is disabled", async () => {
const onOpenChange = vi.fn();
renderDrawer({defaultOpen: true, isDismissable: false, onOpenChange});
runAllTimers();
await user.click(screen.getByRole("button", {name: "Close"}));
runAllTimers();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("exposes placement content modifier and data-placement", async () => {
renderDrawer({defaultOpen: true, placement: "right"});
runAllTimers();
const content = document.querySelector('[data-slot="drawer-content"]');
expect(content).toHaveAttribute("data-placement", "right");
expect(content?.className).toEqual(expect.stringContaining("drawer__content--right"));
});
it("supports controlled isOpen", async () => {
const onOpenChange = vi.fn();
const {rerender} = renderDrawer({isOpen: false, onOpenChange});
runAllTimers();
expect(screen.queryByRole("dialog")).toBeNull();
rerender(<DrawerFixture isOpen onOpenChange={onOpenChange} />);
runAllTimers();
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
runAllTimers();
expect(onOpenChange).toHaveBeenCalledWith(false);
// Controlled: stays open until `isOpen` changes.
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
@@ -0,0 +1,38 @@
import {Button} from "@/components/button";
import {Drawer} from "@/components/drawer";
export type DrawerFixtureProps = {
defaultOpen?: boolean;
isOpen?: boolean;
isDismissable?: boolean;
isKeyboardDismissDisabled?: boolean;
onOpenChange?: (open: boolean) => void;
placement?: "top" | "bottom" | "left" | "right";
};
export const DrawerFixture = (props: DrawerFixtureProps = {}) => (
<Drawer defaultOpen={props.defaultOpen} isOpen={props.isOpen} onOpenChange={props.onOpenChange}>
<Button>Open Drawer</Button>
<Drawer.Backdrop
isDismissable={props.isDismissable}
isKeyboardDismissDisabled={props.isKeyboardDismissDisabled}
>
<Drawer.Content placement={props.placement}>
<Drawer.Dialog>
<Drawer.Handle />
<Drawer.CloseTrigger />
<Drawer.Header>
<Drawer.Heading>Drawer Title</Drawer.Heading>
</Drawer.Header>
<Drawer.Body>
<p>Drawer body content</p>
<Button>Inside action</Button>
</Drawer.Body>
<Drawer.Footer>
<Button slot="close">Confirm</Button>
</Drawer.Footer>
</Drawer.Dialog>
</Drawer.Content>
</Drawer.Backdrop>
</Drawer>
);
@@ -0,0 +1,27 @@
import {render} from "@heroui/testing/browser";
import {page, userEvent} from "vitest/browser";
import {DropdownFixture} from "./fixtures";
const renderDropdown = () => render(<DropdownFixture />);
describe("Dropdown (browser)", () => {
it("opens the menu, shows items, and restores focus to the trigger after Escape", async () => {
await renderDropdown();
const trigger = page.getByRole("button", {name: "Menu"});
await trigger.click();
const menu = page.getByRole("menu");
await expect.element(menu).toBeInTheDocument();
await expect.element(page.getByRole("menuitem", {name: "New file"})).toBeInTheDocument();
await expect.element(page.getByRole("menuitem", {name: "Copy link"})).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await expect.element(menu).not.toBeInTheDocument();
await expect.element(trigger).toHaveFocus();
});
});
@@ -0,0 +1,13 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {DropdownFixture} from "./fixtures";
describe("Dropdown SSR", () => {
it("renders without hydration mismatch when closed", async () => {
await ssrSmoke(<DropdownFixture />);
});
it("renders without hydration mismatch when defaultOpen", async () => {
await ssrSmoke(<DropdownFixture defaultOpen />);
});
});
@@ -0,0 +1,97 @@
import {User, cleanup, render, runAllTimers, screen} from "@heroui/testing/helpers";
import {DropdownFixture} from "./fixtures";
describe("Dropdown", () => {
let testUtilUser: User;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
testUtilUser = new User({
interactionType: "mouse",
advanceTimer: vi.advanceTimersByTime,
});
});
afterEach(() => {
cleanup();
runAllTimers();
vi.useRealTimers();
});
it("exposes trigger data-slot when using Dropdown.Trigger", () => {
render(<DropdownFixture />);
expect(document.querySelector('[data-slot="dropdown-trigger"]')).not.toBeNull();
});
it("supports open and select via Menu tester", async () => {
const onAction = vi.fn();
render(<DropdownFixture onAction={onAction} />);
const tester = testUtilUser.createTester("Menu", {
root: screen.getByRole("button", {name: "Menu"}),
});
expect(tester.getMenu()).toBeNull();
await tester.open();
runAllTimers();
const menu = tester.getMenu();
expect(menu).not.toBeNull();
expect(menu).toHaveAttribute("data-slot", "dropdown-menu");
expect(document.querySelector('[data-slot="dropdown-popover"]')).not.toBeNull();
expect(tester.getOptions()).toHaveLength(3);
await tester.toggleOptionSelection({option: "Copy link"});
runAllTimers();
expect(onAction.mock.calls[0]?.[0]).toBe("copy-link");
expect(tester.getMenu()).toBeNull();
});
it("supports open and select via keyboard", async () => {
const onAction = vi.fn();
render(<DropdownFixture onAction={onAction} />);
const tester = testUtilUser.createTester("Menu", {
root: screen.getByRole("button", {name: "Menu"}),
interactionType: "keyboard",
});
await tester.open();
runAllTimers();
expect(tester.getMenu()).not.toBeNull();
await tester.toggleOptionSelection({option: "Delete file"});
runAllTimers();
expect(onAction.mock.calls[0]?.[0]).toBe("delete-file");
expect(tester.getMenu()).toBeNull();
});
it("supports Escape dismiss without selecting", async () => {
const onAction = vi.fn();
render(<DropdownFixture onAction={onAction} />);
const tester = testUtilUser.createTester("Menu", {
root: screen.getByRole("button", {name: "Menu"}),
});
await tester.open();
runAllTimers();
expect(tester.getMenu()).not.toBeNull();
await tester.close();
runAllTimers();
expect(tester.getMenu()).toBeNull();
expect(onAction).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,28 @@
import type {Key} from "@react-types/shared";
import {Dropdown} from "@/components/dropdown";
import {Label} from "@/components/label";
export type DropdownFixtureProps = {
defaultOpen?: boolean;
onAction?: (key: Key) => void;
};
export const DropdownFixture = (props: DropdownFixtureProps = {}) => (
<Dropdown defaultOpen={props.defaultOpen}>
<Dropdown.Trigger aria-label="Menu">Actions</Dropdown.Trigger>
<Dropdown.Popover>
<Dropdown.Menu onAction={props.onAction}>
<Dropdown.Item id="new-file" textValue="New file">
<Label>New file</Label>
</Dropdown.Item>
<Dropdown.Item id="copy-link" textValue="Copy link">
<Label>Copy link</Label>
</Dropdown.Item>
<Dropdown.Item id="delete-file" textValue="Delete file">
<Label>Delete file</Label>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
@@ -0,0 +1,31 @@
import {render, screen} from "@heroui/testing/helpers";
import {EmptyState} from "@/components/empty-state";
describe("EmptyState", () => {
it("renders default text content when no children are provided", () => {
render(<EmptyState />);
expect(screen.getByText("No results found")).toBeInTheDocument();
});
it("renders custom children", () => {
render(<EmptyState>No matches for your search</EmptyState>);
expect(screen.getByText("No matches for your search")).toBeInTheDocument();
});
it("exposes BEM block and data-slot", () => {
render(<EmptyState data-testid="empty-state" />);
const emptyState = screen.getByTestId("empty-state");
expect(emptyState).toHaveAttribute("data-slot", "empty-state");
expect(emptyState.className).toEqual(expect.stringContaining("empty-state"));
});
it("supports data attribute passthrough", () => {
render(<EmptyState data-foo="bar" data-testid="empty-state" />);
expect(screen.getByTestId("empty-state")).toHaveAttribute("data-foo", "bar");
});
});
@@ -0,0 +1,67 @@
import type {Key} from "react-aria-components";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {useMemo, useState} from "react";
import {ErrorMessage} from "@/components/error-message";
import {Label} from "@/components/label";
import {Tag} from "@/components/tag";
import {TagGroup} from "@/components/tag-group";
describe("ErrorMessage", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("exposes data-slot and BEM block standalone", () => {
render(<ErrorMessage>Something went wrong</ErrorMessage>);
const message = document.querySelector('[data-slot="error-message"]');
expect(message).not.toBeNull();
expect(message?.className).toEqual(expect.stringContaining("error-message"));
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("renders when TagGroup exposes the errorMessage slot", () => {
render(
<TagGroup selectionMode="multiple">
<Label>Amenities</Label>
<TagGroup.List>
<Tag id="laundry">Laundry</Tag>
</TagGroup.List>
<ErrorMessage>Please select at least one category</ErrorMessage>
</TagGroup>,
);
expect(document.querySelector('[data-slot="error-message"]')).not.toBeNull();
expect(screen.getByText("Please select at least one category")).toBeInTheDocument();
});
it("supports gating by selection state inside TagGroup", async () => {
const Example = () => {
const [selected, setSelected] = useState<Iterable<Key>>(new Set());
const isInvalid = useMemo(() => Array.from(selected).length === 0, [selected]);
return (
<TagGroup selectedKeys={selected} selectionMode="multiple" onSelectionChange={setSelected}>
<Label>Amenities</Label>
<TagGroup.List>
<Tag id="laundry">Laundry</Tag>
</TagGroup.List>
{!!isInvalid && <ErrorMessage>Please select at least one category</ErrorMessage>}
</TagGroup>
);
};
render(<Example />);
expect(screen.getByText("Please select at least one category")).toBeInTheDocument();
await user.click(screen.getByRole("row", {name: "Laundry"}));
expect(screen.queryByText("Please select at least one category")).toBeNull();
});
});
@@ -0,0 +1,49 @@
import {render, screen} from "@heroui/testing/helpers";
import {FieldError} from "@/components/field-error";
import {Input} from "@/components/input";
import {Label} from "@/components/label";
import {TextField} from "@/components/textfield";
describe("FieldError", () => {
it("renders with data-slot and BEM when the field is invalid", () => {
render(
<TextField isInvalid name="email">
<Label>Email</Label>
<Input />
<FieldError>Enter a valid email</FieldError>
</TextField>,
);
const error = document.querySelector('[data-slot="field-error"]');
expect(error).not.toBeNull();
expect(error?.className).toEqual(expect.stringContaining("field-error"));
expect(screen.getByText("Enter a valid email")).toBeInTheDocument();
});
it("renders nothing when the field is valid", () => {
render(
<TextField name="email">
<Label>Email</Label>
<Input />
<FieldError>Enter a valid email</FieldError>
</TextField>,
);
expect(screen.queryByText("Enter a valid email")).toBeNull();
expect(document.querySelector('[data-slot="field-error"]')).toBeNull();
});
it("supports render props when invalid", () => {
render(
<TextField isInvalid name="email">
<Label>Email</Label>
<Input />
<FieldError>{({isInvalid}) => (isInvalid ? "Rendered via props" : null)}</FieldError>
</TextField>,
);
expect(screen.getByText("Rendered via props")).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
import {render, screen} from "@heroui/testing/helpers";
import {Fieldset} from "@/components/fieldset";
describe("Fieldset", () => {
it("renders composed legend and fields", () => {
render(
<Fieldset>
<Fieldset.Legend>Profile</Fieldset.Legend>
<Fieldset.Group>
<span>Name field</span>
</Fieldset.Group>
<Fieldset.Actions>
<button type="submit">Save</button>
</Fieldset.Actions>
</Fieldset>,
);
expect(screen.getByText("Profile")).toBeInTheDocument();
expect(screen.getByText("Name field")).toBeInTheDocument();
expect(screen.getByRole("button", {name: "Save"})).toBeInTheDocument();
});
it("exposes BEM block and data-slot", () => {
render(<Fieldset data-testid="fieldset">Content</Fieldset>);
const fieldset = screen.getByTestId("fieldset");
expect(fieldset).toHaveAttribute("data-slot", "fieldset");
expect(fieldset.className).toEqual(expect.stringContaining("fieldset"));
});
it("supports data attribute passthrough", () => {
render(
<Fieldset data-foo="bar" data-testid="fieldset">
Content
</Fieldset>,
);
expect(screen.getByTestId("fieldset")).toHaveAttribute("data-foo", "bar");
});
it("exposes data-disabled from native disabled state", () => {
render(
<Fieldset disabled data-testid="fieldset">
Content
</Fieldset>,
);
expect(screen.getByTestId("fieldset")).toHaveAttribute("data-disabled", "true");
});
describe("composition", () => {
it("exposes data-slot on each sub-part", () => {
render(
<Fieldset>
<Fieldset.Legend data-testid="legend">Profile</Fieldset.Legend>
<Fieldset.Group data-testid="group">Fields</Fieldset.Group>
<Fieldset.Actions data-testid="actions">Actions</Fieldset.Actions>
</Fieldset>,
);
expect(screen.getByTestId("legend")).toHaveAttribute("data-slot", "fieldset-legend");
expect(screen.getByTestId("group")).toHaveAttribute("data-slot", "fieldset-field-group");
expect(screen.getByTestId("actions")).toHaveAttribute("data-slot", "fieldset-actions");
});
});
});
@@ -0,0 +1,22 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {Form} from "@/components/form";
import {Input} from "@/components/input";
import {Label} from "@/components/label";
import {TextField} from "@/components/textfield";
describe("Form SSR", () => {
it("renders without hydration mismatch with a nested TextField", async () => {
const {html} = await ssrSmoke(
<Form aria-label="Profile">
<TextField defaultValue="Ada" name="name">
<Label>Your name</Label>
<Input />
</TextField>
</Form>,
);
expect(html).toContain("<form");
expect(html).toContain('data-slot="textfield"');
});
});
@@ -0,0 +1,66 @@
import type {FormEvent} from "react";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {Button} from "@/components/button";
import {FieldError} from "@/components/field-error";
import {Form} from "@/components/form";
import {Input} from "@/components/input";
import {Label} from "@/components/label";
import {TextField} from "@/components/textfield";
describe("Form", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("renders a form element that nests fields", () => {
render(
<Form aria-label="Profile">
<TextField name="name">
<Label>Your name</Label>
<Input />
</TextField>
</Form>,
);
expect(screen.getByRole("form", {name: "Profile"})).toBeInTheDocument();
expect(screen.getByRole("textbox", {name: "Your name"})).toBeInTheDocument();
});
it("calls onSubmit with form data", async () => {
const onSubmit = vi.fn((e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
});
render(
<Form aria-label="Profile" onSubmit={onSubmit}>
<TextField defaultValue="Ada" name="name">
<Label>Your name</Label>
<Input />
</TextField>
<Button type="submit">Save</Button>
</Form>,
);
await user.click(screen.getByRole("button", {name: "Save"}));
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("renders validationErrors on nested FieldError", () => {
render(
<Form aria-label="Profile" validationErrors={{name: "Name is required"}}>
<TextField name="name">
<Label>Your name</Label>
<Input />
<FieldError />
</TextField>
</Form>,
);
expect(screen.getByText("Name is required")).toBeInTheDocument();
expect(document.querySelector('[data-slot="field-error"]')).not.toBeNull();
});
});
@@ -0,0 +1,40 @@
import {render, screen} from "@heroui/testing/helpers";
import {Header} from "@/components/header";
import {Label} from "@/components/label";
import {ListBox} from "@/components/list-box";
// Header needs a collection ancestor (ListBox.Section).
const renderHeader = (props: {"data-testid"?: string} = {}) =>
render(
<ListBox aria-label="File actions">
<ListBox.Section>
<Header {...props}>Actions</Header>
<ListBox.Item id="new-file" textValue="New file">
<Label>New file</Label>
</ListBox.Item>
</ListBox.Section>
</ListBox>,
);
describe("Header", () => {
it("renders the header content within a section", () => {
renderHeader();
expect(screen.getByText("Actions")).toBeInTheDocument();
});
it("exposes BEM block and data-slot", () => {
renderHeader();
const header = screen.getByText("Actions");
expect(header).toHaveAttribute("data-slot", "header");
expect(header.className).toEqual(expect.stringContaining("header"));
});
it("supports data attribute passthrough", () => {
renderHeader({"data-testid": "section-header"});
expect(screen.getByTestId("section-header")).toBeInTheDocument();
});
});
@@ -0,0 +1,103 @@
import type {ComponentProps} from "react";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {InputGroup} from "@/components/input-group";
import {Label} from "@/components/label";
import {TextField} from "@/components/textfield";
describe("InputGroup", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
const renderGroup = (props: ComponentProps<typeof TextField> = {}) =>
render(
<TextField {...props}>
<Label>Amount</Label>
<InputGroup>
<InputGroup.Prefix>$</InputGroup.Prefix>
<InputGroup.Input />
<InputGroup.Suffix>USD</InputGroup.Suffix>
</InputGroup>
</TextField>,
);
it("exposes compound data-slots under TextField", () => {
renderGroup();
expect(document.querySelector('[data-slot="input-group"]')).not.toBeNull();
expect(document.querySelector('[data-slot="input-group-prefix"]')).not.toBeNull();
expect(document.querySelector('[data-slot="input-group-input"]')).not.toBeNull();
expect(document.querySelector('[data-slot="input-group-suffix"]')).not.toBeNull();
});
it("exposes BEM block on the group", () => {
renderGroup();
expect(document.querySelector('[data-slot="input-group"]')?.className).toEqual(
expect.stringContaining("input-group"),
);
});
it("exposes accessible name on the input", () => {
renderGroup();
expect(screen.getByRole("textbox", {name: "Amount"})).toBeInTheDocument();
});
it("supports focusing the input when Prefix is clicked", async () => {
renderGroup();
const input = screen.getByRole("textbox", {name: "Amount"});
const prefix = document.querySelector('[data-slot="input-group-prefix"]');
expect(prefix).not.toBeNull();
await user.click(prefix!);
expect(input).toHaveFocus();
});
it("supports focusing the input when Suffix is clicked", async () => {
renderGroup();
const input = screen.getByRole("textbox", {name: "Amount"});
const suffix = document.querySelector('[data-slot="input-group-suffix"]');
expect(suffix).not.toBeNull();
await user.click(suffix!);
expect(input).toHaveFocus();
});
it("exposes inherited variant from TextField", () => {
renderGroup({variant: "secondary"});
expect(document.querySelector('[data-slot="input-group"]')?.className).toEqual(
expect.stringContaining("input-group--secondary"),
);
});
it("supports disabling the input via TextField", () => {
renderGroup({isDisabled: true});
expect(screen.getByRole("textbox", {name: "Amount"})).toBeDisabled();
});
it("supports TextArea slot composition", () => {
render(
<TextField>
<Label>Notes</Label>
<InputGroup>
<InputGroup.Prefix>#</InputGroup.Prefix>
<InputGroup.TextArea />
</InputGroup>
</TextField>,
);
expect(document.querySelector('[data-slot="input-group-textarea"]')).not.toBeNull();
expect(screen.getByRole("textbox", {name: "Notes"})).toBeInTheDocument();
});
});
@@ -0,0 +1,26 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {InputOTP} from "@/components/input-otp";
describe("InputOTP SSR", () => {
it("renders without hydration mismatch with grouped slots", async () => {
const {html} = await ssrSmoke(
<InputOTP maxLength={6}>
<InputOTP.Group>
<InputOTP.Slot index={0} />
<InputOTP.Slot index={1} />
<InputOTP.Slot index={2} />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot index={3} />
<InputOTP.Slot index={4} />
<InputOTP.Slot index={5} />
</InputOTP.Group>
</InputOTP>,
);
expect(html).toContain('data-slot="input-otp"');
expect(html).toContain('data-slot="input-otp-slot"');
});
});
@@ -0,0 +1,135 @@
import type {InputOTPProps} from "@/components/input-otp";
import {cleanup, render, screen, setupUser} from "@heroui/testing/helpers";
import {FieldError} from "@/components/field-error";
import {InputOTP} from "@/components/input-otp";
const renderOtp = (props: Omit<Partial<InputOTPProps>, "children"> = {}) => {
return render(
<InputOTP maxLength={6} {...props}>
<InputOTP.Group>
<InputOTP.Slot index={0} />
<InputOTP.Slot index={1} />
<InputOTP.Slot index={2} />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot index={3} />
<InputOTP.Slot index={4} />
<InputOTP.Slot index={5} />
</InputOTP.Group>
</InputOTP>,
);
};
describe("InputOTP", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
afterEach(() => {
// Unmount before jsdom tears down `window` (pending rAF/timeout).
cleanup();
});
it("exposes root and compound data-slots", () => {
renderOtp();
expect(document.querySelector('[data-slot="input-otp"]')).not.toBeNull();
expect(document.querySelectorAll('[data-slot="input-otp-group"]')).toHaveLength(2);
expect(document.querySelectorAll('[data-slot="input-otp-slot"]')).toHaveLength(6);
expect(document.querySelector('[data-slot="input-otp-separator"]')).not.toBeNull();
});
it("exposes BEM block on the container", () => {
renderOtp();
const container = document.querySelector('[data-input-otp-container="true"]');
expect(container?.className).toEqual(expect.stringContaining("input-otp"));
});
it("calls onChange while typing digits", async () => {
const onChange = vi.fn();
renderOtp({onChange});
const input = document.querySelector('[data-slot="input-otp"]') as HTMLInputElement;
expect(input).not.toBeNull();
await user.click(input);
await user.keyboard("12");
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls.at(-1)?.[0]).toBe("12");
});
it("calls onComplete when maxLength is reached", async () => {
const onComplete = vi.fn();
renderOtp({maxLength: 4, onComplete});
const input = document.querySelector('[data-slot="input-otp"]') as HTMLInputElement;
await user.click(input);
await user.keyboard("1234");
expect(onComplete).toHaveBeenCalledWith("1234");
});
it("supports disabled state", async () => {
const onChange = vi.fn();
renderOtp({isDisabled: true, onChange});
const input = document.querySelector('[data-slot="input-otp"]') as HTMLInputElement;
expect(input).toBeDisabled();
expect(input).toHaveAttribute("data-disabled", "true");
await user.click(input);
await user.keyboard("1");
expect(onChange).not.toHaveBeenCalled();
});
it("supports invalid state with FieldError", () => {
render(
<InputOTP isInvalid maxLength={6} validationErrors={["Invalid code"]}>
<InputOTP.Group>
<InputOTP.Slot index={0} />
<InputOTP.Slot index={1} />
<InputOTP.Slot index={2} />
</InputOTP.Group>
<FieldError>Invalid code</FieldError>
</InputOTP>,
);
const root = document.querySelector('[data-slot="input-otp"]');
expect(root).toHaveAttribute("data-invalid", "true");
expect(document.querySelector('[data-slot="input-otp-slot"]')).toHaveAttribute(
"data-invalid",
"true",
);
expect(screen.getByText("Invalid code")).toBeInTheDocument();
});
it("supports filling slots from paste", async () => {
const onChange = vi.fn();
renderOtp({onChange});
const input = document.querySelector('[data-slot="input-otp"]') as HTMLInputElement;
await user.click(input);
await user.paste("654321");
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls.at(-1)?.[0]).toBe("654321");
expect(document.querySelectorAll('[data-filled="true"]')).toHaveLength(6);
});
});
@@ -0,0 +1,55 @@
import type {ComponentProps} from "react";
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {Input} from "@/components/input";
describe("Input", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
const renderInput = (props: ComponentProps<typeof Input> = {}) =>
render(<Input aria-label="Name" placeholder="John" {...props} />);
it("renders with textbox role and accessible name", () => {
renderInput();
expect(screen.getByRole("textbox", {name: "Name"})).toBeInTheDocument();
});
it("exposes BEM block and data-slot", () => {
renderInput();
const input = screen.getByRole("textbox", {name: "Name"});
expect(input).toHaveAttribute("data-slot", "input");
expect(input.className).toEqual(expect.stringContaining("input"));
});
it("exposes variant BEM modifier", () => {
renderInput({variant: "secondary"});
expect(screen.getByRole("textbox", {name: "Name"}).className).toEqual(
expect.stringContaining("input--secondary"),
);
});
it("supports typing and calls onChange", async () => {
const onChange = vi.fn();
renderInput({onChange});
const input = screen.getByRole("textbox", {name: "Name"});
await user.type(input, "Ada");
expect(input).toHaveValue("Ada");
expect(onChange).toHaveBeenCalled();
});
it("supports disabled state", () => {
renderInput({disabled: true});
expect(screen.getByRole("textbox", {name: "Name"})).toBeDisabled();
});
});
@@ -0,0 +1,57 @@
import {render, screen} from "@heroui/testing/helpers";
import {Kbd} from "@/components/kbd";
describe("Kbd", () => {
it("renders abbreviated key and content", () => {
render(
<Kbd>
<Kbd.Abbr keyValue="command" />
<Kbd.Content>K</Kbd.Content>
</Kbd>,
);
expect(screen.getByText("⌘")).toBeInTheDocument();
expect(screen.getByText("K")).toBeInTheDocument();
});
it("exposes a title label for the abbreviated key", () => {
render(
<Kbd>
<Kbd.Abbr keyValue="command" />
</Kbd>,
);
expect(screen.getByTitle("Command")).toBeInTheDocument();
});
it("exposes BEM block class on the root", () => {
render(
<Kbd data-testid="kbd">
<Kbd.Content>Esc</Kbd.Content>
</Kbd>,
);
expect(screen.getByTestId("kbd").className).toEqual(expect.stringContaining("kbd"));
});
it("exposes variant BEM modifier", () => {
render(
<Kbd data-testid="kbd" variant="light">
<Kbd.Content>Esc</Kbd.Content>
</Kbd>,
);
expect(screen.getByTestId("kbd").className).toEqual(expect.stringContaining("kbd--light"));
});
it("supports data attribute passthrough", () => {
render(
<Kbd data-foo="bar" data-testid="kbd">
<Kbd.Content>Esc</Kbd.Content>
</Kbd>,
);
expect(screen.getByTestId("kbd")).toHaveAttribute("data-foo", "bar");
});
});
@@ -0,0 +1,57 @@
import {render, screen} from "@heroui/testing/helpers";
import {Input} from "@/components/input";
import {Label} from "@/components/label";
import {TextField} from "@/components/textfield";
describe("Label", () => {
it("exposes data-slot and BEM block", () => {
render(
<TextField name="name">
<Label>Your name</Label>
<Input />
</TextField>,
);
const label = document.querySelector('[data-slot="label"]');
expect(label).not.toBeNull();
expect(label?.className).toEqual(expect.stringContaining("label"));
expect(screen.getByText("Your name")).toBeInTheDocument();
});
it("exposes isRequired BEM modifier", () => {
render(<Label isRequired>Required</Label>);
expect(document.querySelector('[data-slot="label"]')?.className).toEqual(
expect.stringContaining("label--required"),
);
});
it("exposes isDisabled BEM modifier", () => {
render(<Label isDisabled>Disabled</Label>);
expect(document.querySelector('[data-slot="label"]')?.className).toEqual(
expect.stringContaining("label--disabled"),
);
});
it("exposes isInvalid BEM modifier", () => {
render(<Label isInvalid>Invalid</Label>);
expect(document.querySelector('[data-slot="label"]')?.className).toEqual(
expect.stringContaining("label--invalid"),
);
});
it("exposes association with input via TextField", () => {
render(
<TextField name="name">
<Label>Your name</Label>
<Input />
</TextField>,
);
expect(screen.getByRole("textbox", {name: "Your name"})).toBeInTheDocument();
});
});
@@ -0,0 +1,136 @@
import {render, screen, setupUser} from "@heroui/testing/helpers";
import {Link} from "@/components/link";
describe("Link", () => {
let user: ReturnType<typeof setupUser>;
beforeAll(() => {
user = setupUser();
});
it("renders with role, accessible name, and href", () => {
render(<Link href="https://heroui.com">HeroUI</Link>);
const link = screen.getByRole("link", {name: "HeroUI"});
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://heroui.com");
});
it("exposes BEM block and data-slot", () => {
render(<Link href="#docs">Docs</Link>);
const link = screen.getByRole("link", {name: "Docs"});
expect(link.className).toEqual(expect.stringContaining("link"));
expect(link).toHaveAttribute("data-slot", "link");
});
it("supports data attribute passthrough", () => {
render(
<Link data-foo="bar" data-testid="docs" href="#docs">
Docs
</Link>,
);
const link = screen.getByTestId("docs");
expect(link).toHaveAttribute("data-slot", "link");
expect(link).toHaveAttribute("data-foo", "bar");
});
it("supports hover state", async () => {
render(<Link href="#docs">Docs</Link>);
const link = screen.getByRole("link", {name: "Docs"});
await user.hover(link);
expect(link).toHaveAttribute("data-hovered", "true");
await user.unhover(link);
expect(link).not.toHaveAttribute("data-hovered");
});
it("supports focus-visible via keyboard", async () => {
render(<Link href="#docs">Docs</Link>);
const link = screen.getByRole("link", {name: "Docs"});
await user.tab();
expect(link).toHaveFocus();
expect(link).toHaveAttribute("data-focus-visible", "true");
});
it("calls onPress on click", async () => {
const onPress = vi.fn();
render(
<Link href="#docs" onPress={onPress}>
Docs
</Link>,
);
await user.click(screen.getByRole("link", {name: "Docs"}));
expect(onPress).toHaveBeenCalledTimes(1);
});
it("supports disabled state and blocks press", async () => {
const onPress = vi.fn();
render(
<Link isDisabled href="#docs" onPress={onPress}>
Docs
</Link>,
);
const link = screen.getByRole("link", {name: "Docs"});
expect(link).toHaveAttribute("aria-disabled", "true");
expect(link).toHaveAttribute("data-disabled", "true");
await user.click(link);
expect(onPress).not.toHaveBeenCalled();
});
it("exposes no hover state when disabled", async () => {
render(
<Link isDisabled href="#docs">
Docs
</Link>,
);
const link = screen.getByRole("link", {name: "Docs"});
await user.hover(link);
expect(link).not.toHaveAttribute("data-hovered");
});
describe("Link.Icon", () => {
it("exposes data-slot when composed", () => {
render(
<Link href="https://heroui.com">
HeroUI
<Link.Icon />
</Link>,
);
const icon = document.querySelector('[data-slot="link-icon"]');
expect(icon).not.toBeNull();
expect(icon).toHaveAttribute("data-default-icon", "true");
expect(icon?.querySelector('[data-slot="link-default-icon"]')).not.toBeNull();
});
it("supports custom icon children without default icon marker", () => {
render(
<Link href="https://heroui.com">
HeroUI
<Link.Icon>
<span data-testid="custom-icon"></span>
</Link.Icon>
</Link>,
);
const icon = document.querySelector('[data-slot="link-icon"]');
expect(icon).not.toHaveAttribute("data-default-icon");
expect(screen.getByTestId("custom-icon")).toBeInTheDocument();
expect(icon?.querySelector('[data-slot="link-default-icon"]')).toBeNull();
});
});
});
@@ -0,0 +1,94 @@
import type {Key, Selection} from "@react-types/shared";
import {User, render, screen} from "@heroui/testing/helpers";
import {Header} from "@/components/header";
import {Label} from "@/components/label";
import {ListBox} from "@/components/list-box";
const renderUsers = (props: {onSelectionChange?: (keys: Selection) => void} = {}) => {
return render(
<ListBox
aria-label="Users"
data-testid="users"
selectionMode="single"
onSelectionChange={props.onSelectionChange}
>
<ListBox.Item id="bob" textValue="Bob">
<Label>Bob</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
<ListBox.Item id="fred" textValue="Fred">
<Label>Fred</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
<ListBox.Item id="martha" textValue="Martha">
<Label>Martha</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
</ListBox>,
);
};
describe("ListBox", () => {
let testUtilUser: User;
beforeAll(() => {
testUtilUser = new User({interactionType: "mouse"});
});
it("exposes listbox role, BEM, and data-slot", () => {
renderUsers();
const listbox = screen.getByRole("listbox", {name: "Users"});
expect(listbox).toHaveAttribute("data-slot", "list-box");
expect(listbox.className).toEqual(expect.stringContaining("list-box"));
expect(document.querySelector('[data-slot="list-box-item"]')).not.toBeNull();
});
it("calls onSelectionChange when an option is selected via ListBox tester", async () => {
const onSelectionChange = vi.fn();
renderUsers({onSelectionChange});
const tester = testUtilUser.createTester("ListBox", {
root: screen.getByTestId("users"),
});
expect(tester.getOptions()).toHaveLength(3);
await tester.toggleOptionSelection({option: "Fred"});
expect(onSelectionChange).toHaveBeenCalled();
const selection = onSelectionChange.mock.calls[0]?.[0] as Selection;
expect(selection === "all" ? null : [...selection]).toEqual(["fred"]);
expect(tester.getSelectedOptions()).toHaveLength(1);
});
it("supports sections with onAction when selectionMode is none", async () => {
const onAction = vi.fn<(key: Key) => void>();
render(
<ListBox aria-label="File actions" selectionMode="none" onAction={onAction}>
<ListBox.Section>
<Header>Actions</Header>
<ListBox.Item id="new-file" textValue="New file">
<Label>New file</Label>
</ListBox.Item>
<ListBox.Item id="copy-link" textValue="Copy link">
<Label>Copy link</Label>
</ListBox.Item>
</ListBox.Section>
</ListBox>,
);
const tester = testUtilUser.createTester("ListBox", {
root: screen.getByRole("listbox", {name: "File actions"}),
});
await tester.toggleOptionSelection({option: "Copy link"});
expect(onAction).toHaveBeenCalledWith("copy-link");
});
});
@@ -0,0 +1,30 @@
import type {Key} from "@react-types/shared";
import {MenuTrigger, Popover} from "react-aria-components/Menu";
import {Button} from "@/components/button";
import {Label} from "@/components/label";
import {Menu} from "@/components/menu";
export type MenuFixtureProps = {
onAction?: (key: Key) => void;
};
export const MenuFixture = (props: MenuFixtureProps = {}) => (
<MenuTrigger>
<Button aria-label="Menu">Actions</Button>
<Popover>
<Menu onAction={props.onAction}>
<Menu.Item id="new-file" textValue="New file">
<Label>New file</Label>
</Menu.Item>
<Menu.Item id="copy-link" textValue="Copy link">
<Label>Copy link</Label>
</Menu.Item>
<Menu.Item id="delete-file" textValue="Delete file">
<Label>Delete file</Label>
</Menu.Item>
</Menu>
</Popover>
</MenuTrigger>
);
@@ -0,0 +1,9 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {MenuFixture} from "./fixtures";
describe("Menu SSR", () => {
it("renders without hydration mismatch when closed", async () => {
await ssrSmoke(<MenuFixture />);
});
});
@@ -0,0 +1,133 @@
import type {Key} from "@react-types/shared";
import {User, cleanup, render, runAllTimers, screen} from "@heroui/testing/helpers";
import {MenuTrigger, Popover} from "react-aria-components/Menu";
import {Button} from "@/components/button";
import {Header} from "@/components/header";
import {Label} from "@/components/label";
import {Menu} from "@/components/menu";
import {MenuFixture} from "./fixtures";
const renderMenu = (onAction?: (key: Key) => void) => render(<MenuFixture onAction={onAction} />);
const renderMenuWithSection = (onAction?: (key: Key) => void) => {
return render(
<MenuTrigger>
<Button aria-label="Menu">Actions</Button>
<Popover>
<Menu onAction={onAction}>
<Menu.Section>
<Header>File</Header>
<Menu.Item id="new-file" textValue="New file">
<Label>New file</Label>
</Menu.Item>
<Menu.Item id="copy-link" textValue="Copy link">
<Label>Copy link</Label>
</Menu.Item>
</Menu.Section>
</Menu>
</Popover>
</MenuTrigger>,
);
};
describe("Menu", () => {
let testUtilUser: User;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
testUtilUser = new User({
interactionType: "mouse",
advanceTimer: vi.advanceTimersByTime,
});
});
afterEach(() => {
cleanup();
runAllTimers();
vi.useRealTimers();
});
it("exposes menu data-slot via Menu tester", async () => {
renderMenu();
const tester = testUtilUser.createTester("Menu", {
root: screen.getByRole("button", {name: "Menu"}),
});
expect(tester.getMenu()).toBeNull();
await tester.open();
runAllTimers();
const menu = tester.getMenu();
expect(menu).not.toBeNull();
expect(menu).toHaveAttribute("data-slot", "menu");
expect(menu?.className).toEqual(expect.stringContaining("menu"));
expect(document.querySelector('[data-slot="menu-item"]')).not.toBeNull();
expect(tester.getOptions()).toHaveLength(3);
});
it("calls onAction when an item is selected", async () => {
const onAction = vi.fn();
renderMenu(onAction);
const tester = testUtilUser.createTester("Menu", {
root: screen.getByRole("button", {name: "Menu"}),
});
await tester.open();
runAllTimers();
await tester.toggleOptionSelection({option: "Copy link"});
runAllTimers();
expect(onAction.mock.calls[0]?.[0]).toBe("copy-link");
expect(tester.getMenu()).toBeNull();
});
it("supports Escape dismiss without selecting", async () => {
const onAction = vi.fn();
renderMenu(onAction);
const tester = testUtilUser.createTester("Menu", {
root: screen.getByRole("button", {name: "Menu"}),
});
await tester.open();
runAllTimers();
expect(tester.getMenu()).not.toBeNull();
await tester.close();
runAllTimers();
expect(tester.getMenu()).toBeNull();
expect(onAction).not.toHaveBeenCalled();
});
it("supports Menu.Section with Header and item action", async () => {
const onAction = vi.fn();
renderMenuWithSection(onAction);
const tester = testUtilUser.createTester("Menu", {
root: screen.getByRole("button", {name: "Menu"}),
});
await tester.open();
runAllTimers();
expect(screen.getByText("File")).toBeInTheDocument();
expect(document.querySelector(".menu-section")).not.toBeNull();
await tester.toggleOptionSelection({option: "Copy link"});
runAllTimers();
expect(onAction.mock.calls[0]?.[0]).toBe("copy-link");
});
});
@@ -0,0 +1,88 @@
import {render, screen} from "@heroui/testing/helpers";
import {Label} from "@/components/label";
import {Meter} from "@/components/meter";
describe("Meter", () => {
it("renders with meter role and accessible name", () => {
render(
<Meter value={60}>
<Label>Storage</Label>
<Meter.Output />
<Meter.Track>
<Meter.Fill />
</Meter.Track>
</Meter>,
);
expect(screen.getByRole("meter", {name: "Storage"})).toBeInTheDocument();
});
it("exposes BEM block and data-slot", () => {
render(
<Meter aria-label="Storage" data-testid="meter" value={50}>
<Meter.Track>
<Meter.Fill />
</Meter.Track>
</Meter>,
);
const meter = screen.getByTestId("meter");
expect(meter).toHaveAttribute("data-slot", "meter");
expect(meter.className).toEqual(expect.stringContaining("meter"));
});
it("exposes color BEM modifier", () => {
render(
<Meter aria-label="Storage" color="danger" data-testid="meter" value={50}>
<Meter.Track>
<Meter.Fill />
</Meter.Track>
</Meter>,
);
expect(screen.getByTestId("meter").className).toEqual(expect.stringContaining("meter--danger"));
});
it("supports data attribute passthrough", () => {
render(
<Meter aria-label="Storage" data-foo="bar" data-testid="meter" value={50}>
<Meter.Track>
<Meter.Fill />
</Meter.Track>
</Meter>,
);
expect(screen.getByTestId("meter")).toHaveAttribute("data-foo", "bar");
});
describe("composition", () => {
it("exposes data-slot on each sub-part", () => {
render(
<Meter aria-label="Storage" value={60}>
<Meter.Output data-testid="output" />
<Meter.Track data-testid="track">
<Meter.Fill data-testid="fill" />
</Meter.Track>
</Meter>,
);
expect(screen.getByTestId("output")).toHaveAttribute("data-slot", "meter-output");
expect(screen.getByTestId("track")).toHaveAttribute("data-slot", "meter-track");
expect(screen.getByTestId("fill")).toHaveAttribute("data-slot", "meter-fill");
});
it("renders the formatted value text in the output", () => {
render(
<Meter aria-label="Storage" value={60}>
<Meter.Output data-testid="output" />
<Meter.Track>
<Meter.Fill />
</Meter.Track>
</Meter>,
);
expect(screen.getByTestId("output")).toHaveTextContent("60%");
});
});
});
@@ -0,0 +1,30 @@
import {Button} from "@/components/button";
import {Modal} from "@/components/modal";
export type ModalFixtureProps = {
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const ModalFixture = (props: ModalFixtureProps = {}) => (
<Modal defaultOpen={props.defaultOpen} onOpenChange={props.onOpenChange}>
<Button>Open modal</Button>
<Modal.Backdrop>
<Modal.Container>
<Modal.Dialog>
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>Welcome</Modal.Heading>
</Modal.Header>
<Modal.Body>
<p>Modal body content</p>
<Button>Inside action</Button>
</Modal.Body>
<Modal.Footer>
<Button slot="close">Continue</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
@@ -0,0 +1,49 @@
import {render} from "@heroui/testing/browser";
import {isDocumentScrollLocked} from "@heroui/testing/helpers";
import {page, userEvent} from "vitest/browser";
import {ModalFixture} from "./fixtures";
const renderModal = () => render(<ModalFixture />);
describe("Modal (browser)", () => {
it("supports focus trap, scroll lock, and Escape focus restore", async () => {
await renderModal();
const trigger = page.getByRole("button", {name: "Open modal"});
await trigger.click();
const dialog = page.getByRole("dialog");
await expect.element(dialog).toBeInTheDocument();
expect(isDocumentScrollLocked()).toBe(true);
expect(dialog.element().contains(document.activeElement)).toBe(true);
await userEvent.tab();
expect(dialog.element().contains(document.activeElement)).toBe(true);
await userEvent.keyboard("{Escape}");
await expect.element(dialog).not.toBeInTheDocument();
expect(isDocumentScrollLocked()).toBe(false);
await expect.element(trigger).toHaveFocus();
});
it("supports focus restore after CloseTrigger", async () => {
await renderModal();
const trigger = page.getByRole("button", {name: "Open modal"});
await trigger.click();
const dialog = page.getByRole("dialog");
await expect.element(dialog).toBeInTheDocument();
await page.getByRole("button", {name: "Close"}).click();
await expect.element(dialog).not.toBeInTheDocument();
await expect.element(trigger).toHaveFocus();
});
});
@@ -0,0 +1,13 @@
import {ssrSmoke} from "@heroui/testing/helpers";
import {ModalFixture} from "./fixtures";
describe("Modal SSR", () => {
it("renders without hydration mismatch when closed", async () => {
await ssrSmoke(<ModalFixture />);
});
it("renders without hydration mismatch when defaultOpen", async () => {
await ssrSmoke(<ModalFixture defaultOpen />);
});
});
@@ -0,0 +1,84 @@
import {User, cleanup, render, runAllTimers, screen, setupUser} from "@heroui/testing/helpers";
import {ModalFixture} from "./fixtures";
const renderModal = (props: {defaultOpen?: boolean; onOpenChange?: (open: boolean) => void} = {}) =>
render(<ModalFixture {...props} />);
describe("Modal", () => {
let user: ReturnType<typeof setupUser>;
let testUtilUser: User;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
user = setupUser({advanceTimers: vi.advanceTimersByTime});
testUtilUser = new User({
interactionType: "mouse",
advanceTimer: vi.advanceTimersByTime,
});
});
afterEach(() => {
cleanup();
runAllTimers();
vi.useRealTimers();
});
it("exposes compound slots via Dialog tester", async () => {
renderModal();
const tester = testUtilUser.createTester("Dialog", {
root: screen.getByRole("button", {name: "Open modal"}),
overlayType: "modal",
});
expect(tester.getDialog()).toBeNull();
await tester.open();
runAllTimers();
const dialog = tester.getDialog();
expect(dialog).not.toBeNull();
expect(dialog).toHaveAttribute("data-slot", "modal-dialog");
expect(dialog?.className).toEqual(expect.stringContaining("modal__dialog"));
expect(document.querySelector('[data-slot="modal-backdrop"]')).not.toBeNull();
expect(document.querySelector('[data-slot="modal-container"]')).not.toBeNull();
expect(document.querySelector('[data-slot="modal-header"]')).not.toBeNull();
expect(document.querySelector('[data-slot="modal-body"]')).not.toBeNull();
expect(document.querySelector('[data-slot="modal-footer"]')).not.toBeNull();
expect(screen.getByRole("heading", {name: "Welcome"})).toBeInTheDocument();
await tester.close();
runAllTimers();
expect(tester.getDialog()).toBeNull();
});
it("supports Escape dismiss", async () => {
const onOpenChange = vi.fn();
renderModal({defaultOpen: true, onOpenChange});
runAllTimers();
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
runAllTimers();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("supports closing via CloseTrigger", async () => {
const onOpenChange = vi.fn();
renderModal({defaultOpen: true, onOpenChange});
runAllTimers();
await user.click(document.querySelector('[data-slot="modal-close-trigger"]')!);
runAllTimers();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});

Some files were not shown because too many files have changed in this diff Show More