V3.2.4 (#6758)
* 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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -12,6 +12,8 @@ node_modules
|
||||
# testing
|
||||
coverage
|
||||
__snapshots__
|
||||
**/__screenshots__/
|
||||
**/.vitest-attachments/
|
||||
coverage.json
|
||||
|
||||
# next.js
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
@@ -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"
|
||||
|
||||
@@ -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 — 补丁版本",
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@ async function generateExports() {
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./styles": {
|
||||
default: "./dist/styles.css",
|
||||
style: "./dist/styles.css",
|
||||
default: "./dist/styles.css",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
{children}
|
||||
<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
Reference in New Issue
Block a user