Compare commits

...

16 Commits

Author SHA1 Message Date
Alan Buscaglia 20b93388ec docs(skills): add UI motion guidance
- Document visible microinteraction rules for shadcn primitives
- Register the skill for motion and transition work
- Link skeleton guidance for loading handoff cases
2026-06-05 22:24:10 +02:00
Alan Buscaglia 0fb0807a22 feat(ui): add nested skeleton handoffs
- Reveal loaded content for nested shadcn skeleton states
- Cover finding drawers, modals, and inline resource tables
- Extend handoffs to alert, mutelist, compliance, and overview surfaces
2026-06-05 22:15:16 +02:00
Alan Buscaglia 753a4eda62 feat(ui): add skeleton loading handoffs
- Add reusable shadcn skeleton scanner and reveal primitives
- Wrap page-level loading states with skeleton content handoffs
- Document skeleton usage through a project skill
2026-06-05 22:13:55 +02:00
Alan Buscaglia f4051d52d9 feat(ui): add expandable microinteractions
- Add visible open and close motion to collapsible content
- Animate tree row, chevron, and selection feedback
- Cover expandable motion behavior with focused unit tests
2026-06-05 21:13:15 +02:00
Alan Buscaglia adbe67d2f3 feat(ui): add form control microinteractions
- Add visible focus and clear feedback to search inputs
- Animate radio, text input, textarea, and file dropzone states
- Cover form control motion with focused unit tests
2026-06-05 21:07:12 +02:00
Alan Buscaglia 65c0425729 fix(ui): animate finding group collapse
- Keep after-row content mounted during exit
- Animate inline resource container close state
- Preserve existing finding group table behavior
2026-06-05 14:55:00 +02:00
Alan Buscaglia 5828cce644 feat(ui): add Combobox trigger transition
- Animate combobox trigger and chevron state
- Keep trigger accessible with a stable label
- Cover Combobox motion with focused unit tests
2026-06-05 14:54:52 +02:00
Alan Buscaglia 87bd2e78a1 feat(ui): add Drawer content transition
- Animate drawer overlay and directional content states
- Preserve reduced-motion behavior
- Cover Drawer motion with focused unit tests
2026-06-05 14:54:45 +02:00
Alan Buscaglia ccae4afe68 feat(ui): add Dialog content transition
- Animate dialog overlay and content states
- Preserve reduced-motion behavior
- Cover Dialog motion with focused unit tests
2026-06-05 14:54:37 +02:00
Alan Buscaglia 0e2bb99f02 feat(ui): add Tabs content transition
- Animate tab panels when switching active content
- Preserve reduced-motion behavior
- Cover shared Tabs motion with focused unit tests
2026-06-05 14:49:09 +02:00
Alan Buscaglia 8fb59682d5 feat(ui): add multiselect selection microinteractions
- Animate selected pills and item feedback in multiselect components
- Add checkbox state transitions for provider group selections
- Cover shared selection motion with focused unit tests
2026-06-05 14:44:25 +02:00
Alan Buscaglia 799f062ee0 feat(ui): add Tooltip open close microinteraction
- Add visible Tooltip motion timing and easing

- Preserve reduced-motion fallbacks

- Cover Tooltip motion contract with unit tests
2026-06-05 13:47:11 +02:00
Alan Buscaglia 51945f5cc5 feat(ui): add Dropdown open close microinteraction
- Add visible Dropdown motion timing and easing

- Align submenu motion with dropdown content

- Cover reduced-motion behavior with unit tests
2026-06-05 13:46:53 +02:00
Alan Buscaglia b93e3f9d04 feat(ui): add Select open close microinteraction
- Add visible Select close motion without open-state conflicts
- Preserve reduced-motion behavior
- Cover controlled and uncontrolled close flows
2026-06-05 13:04:16 +02:00
Alan Buscaglia ef4d05a782 feat(ui): add Popover open close microinteraction
- Add explicit timing for Popover state transitions
- Add reduced-motion fallback utilities
- Cover controlled Popover motion behavior
2026-06-05 12:16:27 +02:00
Alan Buscaglia 7185e539c8 feat(ui): add Button press microinteraction
- Add targeted transition recipe for shared Button states
- Add press and reduced-motion behavior
- Cover link and menu motion exceptions
2026-06-05 12:00:35 +02:00
79 changed files with 2777 additions and 664 deletions
+133 -128
View File
@@ -12,135 +12,139 @@ Use these skills for detailed patterns on-demand:
### Generic Skills (Any Project)
| Skill | Description | URL |
|-------|-------------|-----|
| `typescript` | Const types, flat interfaces, utility types | [SKILL.md](skills/typescript/SKILL.md) |
| `react-19` | No useMemo/useCallback, React Compiler | [SKILL.md](skills/react-19/SKILL.md) |
| `nextjs-16` | App Router, Server Actions, proxy.ts, streaming | [SKILL.md](skills/nextjs-16/SKILL.md) |
| `tailwind-4` | cn() utility, no var() in className | [SKILL.md](skills/tailwind-4/SKILL.md) |
| `playwright` | Page Object Model, MCP workflow, selectors | [SKILL.md](skills/playwright/SKILL.md) |
| `pytest` | Fixtures, mocking, markers, parametrize | [SKILL.md](skills/pytest/SKILL.md) |
| `django-drf` | ViewSets, Serializers, Filters | [SKILL.md](skills/django-drf/SKILL.md) |
| `jsonapi` | Strict JSON:API v1.1 spec compliance | [SKILL.md](skills/jsonapi/SKILL.md) |
| `zod-4` | New API (z.email(), z.uuid()) | [SKILL.md](skills/zod-4/SKILL.md) |
| `zustand-5` | Persist, selectors, slices | [SKILL.md](skills/zustand-5/SKILL.md) |
| `ai-sdk-5` | UIMessage, streaming, LangChain | [SKILL.md](skills/ai-sdk-5/SKILL.md) |
| `vitest` | Unit testing, React Testing Library | [SKILL.md](skills/vitest/SKILL.md) |
| `tdd` | Test-Driven Development workflow | [SKILL.md](skills/tdd/SKILL.md) |
| Skill | Description | URL |
| ------------ | ----------------------------------------------- | -------------------------------------- |
| `typescript` | Const types, flat interfaces, utility types | [SKILL.md](skills/typescript/SKILL.md) |
| `react-19` | No useMemo/useCallback, React Compiler | [SKILL.md](skills/react-19/SKILL.md) |
| `nextjs-16` | App Router, Server Actions, proxy.ts, streaming | [SKILL.md](skills/nextjs-16/SKILL.md) |
| `tailwind-4` | cn() utility, no var() in className | [SKILL.md](skills/tailwind-4/SKILL.md) |
| `playwright` | Page Object Model, MCP workflow, selectors | [SKILL.md](skills/playwright/SKILL.md) |
| `pytest` | Fixtures, mocking, markers, parametrize | [SKILL.md](skills/pytest/SKILL.md) |
| `django-drf` | ViewSets, Serializers, Filters | [SKILL.md](skills/django-drf/SKILL.md) |
| `jsonapi` | Strict JSON:API v1.1 spec compliance | [SKILL.md](skills/jsonapi/SKILL.md) |
| `zod-4` | New API (z.email(), z.uuid()) | [SKILL.md](skills/zod-4/SKILL.md) |
| `zustand-5` | Persist, selectors, slices | [SKILL.md](skills/zustand-5/SKILL.md) |
| `ai-sdk-5` | UIMessage, streaming, LangChain | [SKILL.md](skills/ai-sdk-5/SKILL.md) |
| `vitest` | Unit testing, React Testing Library | [SKILL.md](skills/vitest/SKILL.md) |
| `tdd` | Test-Driven Development workflow | [SKILL.md](skills/tdd/SKILL.md) |
### Prowler-Specific Skills
| Skill | Description | URL |
|-------|-------------|-----|
| `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) |
| `prowler-api` | Django + RLS + JSON:API patterns | [SKILL.md](skills/prowler-api/SKILL.md) |
| `prowler-ui` | Next.js + shadcn conventions | [SKILL.md](skills/prowler-ui/SKILL.md) |
| `prowler-sdk-check` | Create new security checks | [SKILL.md](skills/prowler-sdk-check/SKILL.md) |
| `prowler-mcp` | MCP server tools and models | [SKILL.md](skills/prowler-mcp/SKILL.md) |
| `prowler-test-sdk` | SDK testing (pytest + moto) | [SKILL.md](skills/prowler-test-sdk/SKILL.md) |
| `prowler-test-api` | API testing (pytest-django + RLS) | [SKILL.md](skills/prowler-test-api/SKILL.md) |
| `prowler-test-ui` | E2E testing (Playwright) | [SKILL.md](skills/prowler-test-ui/SKILL.md) |
| `prowler-compliance` | Compliance framework structure | [SKILL.md](skills/prowler-compliance/SKILL.md) |
| `prowler-compliance-review` | Review compliance framework PRs | [SKILL.md](skills/prowler-compliance-review/SKILL.md) |
| `prowler-provider` | Add new cloud providers | [SKILL.md](skills/prowler-provider/SKILL.md) |
| `prowler-changelog` | Changelog entries (keepachangelog.com) | [SKILL.md](skills/prowler-changelog/SKILL.md) |
| `prowler-ci` | CI checks and PR gates (GitHub Actions) | [SKILL.md](skills/prowler-ci/SKILL.md) |
| `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) |
| `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) |
| `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) |
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
| Skill | Description | URL |
| ---------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |
| `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) |
| `prowler-api` | Django + RLS + JSON:API patterns | [SKILL.md](skills/prowler-api/SKILL.md) |
| `prowler-ui` | Next.js + shadcn conventions | [SKILL.md](skills/prowler-ui/SKILL.md) |
| `prowler-ui-motion` | shadcn/Radix visible microinteraction conventions | [SKILL.md](skills/prowler-ui-motion/SKILL.md) |
| `prowler-ui-skeletons` | shadcn skeleton loading and content reveal conventions | [SKILL.md](skills/prowler-ui-skeletons/SKILL.md) |
| `prowler-sdk-check` | Create new security checks | [SKILL.md](skills/prowler-sdk-check/SKILL.md) |
| `prowler-mcp` | MCP server tools and models | [SKILL.md](skills/prowler-mcp/SKILL.md) |
| `prowler-test-sdk` | SDK testing (pytest + moto) | [SKILL.md](skills/prowler-test-sdk/SKILL.md) |
| `prowler-test-api` | API testing (pytest-django + RLS) | [SKILL.md](skills/prowler-test-api/SKILL.md) |
| `prowler-test-ui` | E2E testing (Playwright) | [SKILL.md](skills/prowler-test-ui/SKILL.md) |
| `prowler-compliance` | Compliance framework structure | [SKILL.md](skills/prowler-compliance/SKILL.md) |
| `prowler-compliance-review` | Review compliance framework PRs | [SKILL.md](skills/prowler-compliance-review/SKILL.md) |
| `prowler-provider` | Add new cloud providers | [SKILL.md](skills/prowler-provider/SKILL.md) |
| `prowler-changelog` | Changelog entries (keepachangelog.com) | [SKILL.md](skills/prowler-changelog/SKILL.md) |
| `prowler-ci` | CI checks and PR gates (GitHub Actions) | [SKILL.md](skills/prowler-ci/SKILL.md) |
| `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) |
| `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) |
| `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) |
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
### Auto-invoke Skills
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Action | Skill |
|--------|-------|
| Add changelog entry for a PR or feature | `prowler-changelog` |
| Adding DRF pagination or permissions | `django-drf` |
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
| Adding indexes or constraints to database tables | `django-migration-psql` |
| Adding new providers | `prowler-provider` |
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
| Adding services to existing providers | `prowler-provider` |
| After creating/modifying a skill | `skill-sync` |
| App Router / Server Actions | `nextjs-16` |
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
| Building AI chat features | `ai-sdk-5` |
| Committing changes | `prowler-commit` |
| Configuring MCP servers in agentic workflows | `gh-aw` |
| Create PR that requires changelog entry | `prowler-changelog` |
| Create a PR with gh pr create | `prowler-pr` |
| Creating API endpoints | `jsonapi` |
| Creating Attack Paths queries | `prowler-attack-paths-query` |
| Creating GitHub Agentic Workflows | `gh-aw` |
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
| Creating Zod schemas | `zod-4` |
| Creating a git commit | `prowler-commit` |
| Creating new checks | `prowler-sdk-check` |
| Creating new skills | `skill-creator` |
| Creating or reviewing Django migrations | `django-migration-psql` |
| Creating/modifying Prowler UI components | `prowler-ui` |
| Creating/modifying models, views, serializers | `prowler-api` |
| Creating/updating compliance frameworks | `prowler-compliance` |
| Debug why a GitHub Actions job is failing | `prowler-ci` |
| Debugging gh-aw compilation errors | `gh-aw` |
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
| Fixing bug | `tdd` |
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
| General Prowler development questions | `prowler` |
| Implementing JSON:API endpoints | `django-drf` |
| Implementing feature | `tdd` |
| Importing Copilot Custom Agents into workflows | `gh-aw` |
| Inspect PR CI checks and gates (.github/workflows/*) | `prowler-ci` |
| Inspect PR CI workflows (.github/workflows/*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` |
| Mapping checks to compliance controls | `prowler-compliance` |
| Mocking AWS with moto in tests | `prowler-test-sdk` |
| Modifying API responses | `jsonapi` |
| Modifying component | `tdd` |
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
| Refactoring code | `tdd` |
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
| Review changelog format and conventions | `prowler-changelog` |
| Reviewing JSON:API compliance | `jsonapi` |
| Reviewing compliance framework PRs | `prowler-compliance-review` |
| Running makemigrations or pgmakemigrations | `django-migration-psql` |
| Syncing compliance framework with upstream catalog | `prowler-compliance` |
| Testing RLS tenant isolation | `prowler-test-api` |
| Testing hooks or utilities | `vitest` |
| Troubleshoot why a skill is missing from AGENTS.md auto-invoke | `skill-sync` |
| Understand CODEOWNERS/labeler-based automation | `prowler-ci` |
| Understand PR title conventional-commit validation | `prowler-ci` |
| Understand changelog gate and no-changelog label behavior | `prowler-ci` |
| Understand review ownership with CODEOWNERS | `prowler-pr` |
| Update CHANGELOG.md in any component | `prowler-changelog` |
| Updating README.md provider statistics table | `prowler-readme-table` |
| Updating checks, services, compliance, or categories count in README.md | `prowler-readme-table` |
| Updating existing Attack Paths queries | `prowler-attack-paths-query` |
| Updating existing checks and metadata | `prowler-sdk-check` |
| Using Zustand stores | `zustand-5` |
| Working on MCP server tools | `prowler-mcp` |
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
| Working on task | `tdd` |
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
| Working with Tailwind classes | `tailwind-4` |
| Writing Playwright E2E tests | `playwright` |
| Writing Prowler API tests | `prowler-test-api` |
| Writing Prowler SDK tests | `prowler-test-sdk` |
| Writing Prowler UI E2E tests | `prowler-test-ui` |
| Writing Python tests with pytest | `pytest` |
| Writing React component tests | `vitest` |
| Writing React components | `react-19` |
| Writing TypeScript types/interfaces | `typescript` |
| Writing Vitest tests | `vitest` |
| Writing data backfill or data migration | `django-migration-psql` |
| Writing documentation | `prowler-docs` |
| Writing unit tests for UI | `vitest` |
| Action | Skill |
| --------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Add changelog entry for a PR or feature | `prowler-changelog` |
| Adding DRF pagination or permissions | `django-drf` |
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
| Adding indexes or constraints to database tables | `django-migration-psql` |
| Adding new providers | `prowler-provider` |
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
| Adding services to existing providers | `prowler-provider` |
| After creating/modifying a skill | `skill-sync` |
| App Router / Server Actions | `nextjs-16` |
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
| Building AI chat features | `ai-sdk-5` |
| Committing changes | `prowler-commit` |
| Configuring MCP servers in agentic workflows | `gh-aw` |
| Create PR that requires changelog entry | `prowler-changelog` |
| Create a PR with gh pr create | `prowler-pr` |
| Creating API endpoints | `jsonapi` |
| Creating Attack Paths queries | `prowler-attack-paths-query` |
| Creating GitHub Agentic Workflows | `gh-aw` |
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
| Creating Zod schemas | `zod-4` |
| Creating a git commit | `prowler-commit` |
| Creating new checks | `prowler-sdk-check` |
| Creating new skills | `skill-creator` |
| Creating or reviewing Django migrations | `django-migration-psql` |
| Creating/modifying Prowler UI components | `prowler-ui` |
| Creating/modifying UI motion, transitions, or microinteractions | `prowler-ui-motion` |
| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` |
| Creating/modifying models, views, serializers | `prowler-api` |
| Creating/updating compliance frameworks | `prowler-compliance` |
| Debug why a GitHub Actions job is failing | `prowler-ci` |
| Debugging gh-aw compilation errors | `gh-aw` |
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
| Fixing bug | `tdd` |
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
| General Prowler development questions | `prowler` |
| Implementing JSON:API endpoints | `django-drf` |
| Implementing feature | `tdd` |
| Importing Copilot Custom Agents into workflows | `gh-aw` |
| Inspect PR CI checks and gates (.github/workflows/\*) | `prowler-ci` |
| Inspect PR CI workflows (.github/workflows/\*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` |
| Mapping checks to compliance controls | `prowler-compliance` |
| Mocking AWS with moto in tests | `prowler-test-sdk` |
| Modifying API responses | `jsonapi` |
| Modifying component | `tdd` |
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
| Refactoring code | `tdd` |
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
| Review changelog format and conventions | `prowler-changelog` |
| Reviewing JSON:API compliance | `jsonapi` |
| Reviewing compliance framework PRs | `prowler-compliance-review` |
| Running makemigrations or pgmakemigrations | `django-migration-psql` |
| Syncing compliance framework with upstream catalog | `prowler-compliance` |
| Testing RLS tenant isolation | `prowler-test-api` |
| Testing hooks or utilities | `vitest` |
| Troubleshoot why a skill is missing from AGENTS.md auto-invoke | `skill-sync` |
| Understand CODEOWNERS/labeler-based automation | `prowler-ci` |
| Understand PR title conventional-commit validation | `prowler-ci` |
| Understand changelog gate and no-changelog label behavior | `prowler-ci` |
| Understand review ownership with CODEOWNERS | `prowler-pr` |
| Update CHANGELOG.md in any component | `prowler-changelog` |
| Updating README.md provider statistics table | `prowler-readme-table` |
| Updating checks, services, compliance, or categories count in README.md | `prowler-readme-table` |
| Updating existing Attack Paths queries | `prowler-attack-paths-query` |
| Updating existing checks and metadata | `prowler-sdk-check` |
| Using Zustand stores | `zustand-5` |
| Working on MCP server tools | `prowler-mcp` |
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
| Working on task | `tdd` |
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
| Working with Tailwind classes | `tailwind-4` |
| Writing Playwright E2E tests | `playwright` |
| Writing Prowler API tests | `prowler-test-api` |
| Writing Prowler SDK tests | `prowler-test-sdk` |
| Writing Prowler UI E2E tests | `prowler-test-ui` |
| Writing Python tests with pytest | `pytest` |
| Writing React component tests | `vitest` |
| Writing React components | `react-19` |
| Writing TypeScript types/interfaces | `typescript` |
| Writing Vitest tests | `vitest` |
| Writing data backfill or data migration | `django-migration-psql` |
| Writing documentation | `prowler-docs` |
| Writing unit tests for UI | `vitest` |
---
@@ -148,13 +152,13 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
Prowler is an open-source cloud security assessment tool supporting AWS, Azure, GCP, Kubernetes, GitHub, M365, and more.
| Component | Location | Tech Stack |
|-----------|----------|------------|
| SDK | `prowler/` | Python 3.10+, uv |
| API | `api/` | Django 5.1, DRF, Celery |
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
| Dashboard | `dashboard/` | Dash, Plotly |
| Component | Location | Tech Stack |
| ---------- | ------------- | -------------------------------- |
| SDK | `prowler/` | Python 3.10+, uv |
| API | `api/` | Django 5.1, DRF, Celery |
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
| Dashboard | `dashboard/` | Dash, Plotly |
---
@@ -180,6 +184,7 @@ Follow conventional-commit style: `<type>[scope]: <description>`
**Types:** `feat`, `fix`, `docs`, `chore`, `perf`, `refactor`, `style`, `test`
Before creating a PR:
1. Complete checklist in `.github/pull_request_template.md`
2. Run all relevant tests and linters
3. Link screenshots for UI changes
+63
View File
@@ -0,0 +1,63 @@
---
name: prowler-ui-motion
description: >
Prowler UI visible microinteraction rules for shadcn primitives, forms, tabs, expandable rows, status states, and motion QA.
Trigger: Creating/modifying UI motion, transitions, animations, microinteractions, Radix/shadcn primitives, or interactive table rows.
license: Apache-2.0
metadata:
author: prowler-cloud
version: "1.0"
scope: [root, ui]
auto_invoke:
- "Creating/modifying UI motion"
- "Creating/modifying microinteractions"
- "Creating/modifying shadcn primitives"
- "Creating/modifying expandable rows"
---
## When to Use
Use this skill before adding or changing visible UI motion in Prowler: shadcn/Radix primitives, form controls, overlays, tabs, expandable rows, table affordances, status badges, progress, spinners, skeleton handoffs, and icon-only actions.
## Critical Patterns
- Prefer shadcn/Tailwind motion; do not add new HeroUI motion surfaces.
- Motion must be visible, not theoretical: use durations humans can perceive (`200ms700ms` depending on scope).
- Preserve Radix state semantics; animate via classes, force-mounted indicators, `data-state`, or `asChild` wrappers without breaking accessibility.
- Always include `motion-reduce` behavior for transform/animation-heavy changes.
- Opening and closing must both animate when the component supports unmount/exit behavior.
- Keep row/background transitions separate from control/checkmark transitions; do not couple table selection backgrounds to checkbox internals.
- Avoid feature-local motion copies when a shared primitive owns the interaction.
- For skeleton/loading handoffs, load `prowler-ui-skeletons` and follow its boundary/reveal rules.
## Decision Gates
| Surface | Required motion contract |
| --------------------------------------------------- | ---------------------------------------------------------------------------------- |
| Dialog, Drawer, Popover, Dropdown, Select, Combobox | Enter and exit motion; preserve focus/portal behavior. |
| Tabs | Content switch should fade/slide; inactive panels must not flash. |
| Checkbox, Radio, pills, badges | Background/icon/content state changes should transition together. |
| Input, SearchInput, Textarea, Dropzone | Focus border, clear button, placeholder/selection affordances need visible timing. |
| Collapsible, Tree, expandable table row | Expand and collapse must both animate height/opacity/chevron. |
| StatusBadge, Progress, Spinner | State/color/value changes should feel smooth and respect reduced motion. |
| Data table affordances | Row hover/selection may animate, but do not break table layout semantics. |
## Execution Steps
1. Identify whether the interaction belongs to a shared primitive or a feature-local surface.
2. Add the smallest shared motion primitive that covers the behavior.
3. Verify enter and exit paths, including closed/unmounted states.
4. Add focused unit tests for shared primitives or reusable motion contracts.
5. Provide at least one real UI route/flow where the user can visually test the motion.
## Commands
```bash
cd ui && pnpm run typecheck
cd ui && pnpm test:unit <focused-test-files>
```
## Resources
- `skills/prowler-ui-skeletons/SKILL.md` — skeleton scanner and content reveal rules.
- `skills/prowler-ui/SKILL.md` — component placement and shadcn vs HeroUI rules.
+60
View File
@@ -0,0 +1,60 @@
---
name: prowler-ui-skeletons
description: "Trigger: skeleton, loading state, Suspense fallback, content reveal, shimmer. Use Prowler shadcn skeletons correctly."
license: Apache-2.0
metadata:
author: prowler-cloud
version: "1.0"
scope: [root, ui]
auto_invoke:
- "Creating/modifying skeletons"
- "Creating/modifying loading states"
- "Adding Suspense fallbacks"
---
## Activation Contract
Use this skill before creating or modifying any Prowler UI skeleton, loading placeholder, Suspense fallback, or loading-to-content transition.
## Hard Rules
- Prefer shadcn `Skeleton` from `@/components/shadcn`; do not add new HeroUI skeletons.
- Do not mix HeroUI and shadcn inside the same new loading surface.
- Keep scanner/shimmer behavior centralized in shadcn `Skeleton`; never duplicate scanner CSS in feature files.
- For Suspense data loading, wrap the boundary with `SkeletonBoundary` so fallback removal and real content reveal are paired.
- For client-state loading (`isLoading`, drawers, modals, expanded rows), add a reveal wrapper around the resolved content, not around the skeleton.
- Respect `motion-reduce`; every animation must degrade to no transform/transition.
- Preserve layout stability: skeleton dimensions must match the final content as closely as practical.
- Do not migrate legacy/HeroUI skeletons unless the task explicitly includes that migration.
## Decision Gates
| Situation | Action |
| --- | --- |
| Page/server data with `Suspense` fallback | Use `SkeletonBoundary` with the skeleton fallback. |
| Nested Suspense inside tab/chart content | Use `SkeletonBoundary` unless the fallback is legacy/HeroUI. |
| Client state swaps skeleton to content | Keep shadcn `Skeleton`; wrap resolved content with `SkeletonContentReveal` or an equivalent shared reveal. |
| Existing HeroUI skeleton | Leave unchanged unless migration is explicitly requested. |
| Text-only `Loading...` fallback | Replace only if the requested scope includes that surface. |
## Execution Steps
1. Identify whether the skeleton is shadcn, HeroUI legacy, or text-only fallback.
2. If shadcn + Suspense, use `SkeletonBoundary` instead of raw `Suspense`.
3. If shadcn + client state, keep the skeleton fallback and reveal only the loaded content.
4. Verify reduced-motion classes remain present.
5. Add or update focused tests when changing shared skeleton primitives or reusable boundaries.
## Output Contract
Report:
- Which loading surfaces changed.
- Whether each surface is Suspense-boundary or client-state loading.
- Which legacy/HeroUI skeletons were intentionally left untouched.
- Test/typecheck evidence when implementation changes are made.
## References
- `ui/components/shadcn/skeleton/skeleton.tsx`
- `ui/components/shadcn/skeleton/skeleton-boundary.tsx`
- `ui/components/shadcn/skeleton/skeleton-content-reveal.tsx`
+32 -29
View File
@@ -3,6 +3,8 @@
> **Skills Reference**: For detailed patterns, use these skills:
>
> - [`prowler-ui`](../skills/prowler-ui/SKILL.md) - Prowler-specific UI patterns
> - [`prowler-ui-motion`](../skills/prowler-ui-motion/SKILL.md) - shadcn/Radix visible microinteraction conventions
> - [`prowler-ui-skeletons`](../skills/prowler-ui-skeletons/SKILL.md) - shadcn skeleton loading and content reveal conventions
> - [`prowler-test-ui`](../skills/prowler-test-ui/SKILL.md) - Playwright E2E testing (comprehensive)
> - [`typescript`](../skills/typescript/SKILL.md) - Const types, flat interfaces
> - [`react-19`](../skills/react-19/SKILL.md) - No useMemo/useCallback, compiler
@@ -19,35 +21,36 @@
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Action | Skill |
| -------------------------------------------------------------- | ------------------- |
| Add changelog entry for a PR or feature | `prowler-changelog` |
| App Router / Server Actions | `nextjs-16` |
| Building AI chat features | `ai-sdk-5` |
| Committing changes | `prowler-commit` |
| Create PR that requires changelog entry | `prowler-changelog` |
| Creating Zod schemas | `zod-4` |
| Creating a git commit | `prowler-commit` |
| Creating/modifying Prowler UI components | `prowler-ui` |
| Fixing bug | `tdd` |
| Implementing feature | `tdd` |
| Modifying component | `tdd` |
| Refactoring code | `tdd` |
| Review changelog format and conventions | `prowler-changelog` |
| Testing hooks or utilities | `vitest` |
| Update CHANGELOG.md in any component | `prowler-changelog` |
| Using Zustand stores | `zustand-5` |
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
| Working on task | `tdd` |
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
| Working with Tailwind classes | `tailwind-4` |
| Writing Playwright E2E tests | `playwright` |
| Writing Prowler UI E2E tests | `prowler-test-ui` |
| Writing React component tests | `vitest` |
| Writing React components | `react-19` |
| Writing TypeScript types/interfaces | `typescript` |
| Writing Vitest tests | `vitest` |
| Writing unit tests for UI | `vitest` |
| Action | Skill |
| ------------------------------------------------------------------- | ---------------------- |
| Add changelog entry for a PR or feature | `prowler-changelog` |
| App Router / Server Actions | `nextjs-16` |
| Building AI chat features | `ai-sdk-5` |
| Committing changes | `prowler-commit` |
| Create PR that requires changelog entry | `prowler-changelog` |
| Creating Zod schemas | `zod-4` |
| Creating a git commit | `prowler-commit` |
| Creating/modifying Prowler UI components | `prowler-ui` |
| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` |
| Fixing bug | `tdd` |
| Implementing feature | `tdd` |
| Modifying component | `tdd` |
| Refactoring code | `tdd` |
| Review changelog format and conventions | `prowler-changelog` |
| Testing hooks or utilities | `vitest` |
| Update CHANGELOG.md in any component | `prowler-changelog` |
| Using Zustand stores | `zustand-5` |
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
| Working on task | `tdd` |
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
| Working with Tailwind classes | `tailwind-4` |
| Writing Playwright E2E tests | `playwright` |
| Writing Prowler UI E2E tests | `prowler-test-ui` |
| Writing React component tests | `vitest` |
| Writing React components | `react-19` |
| Writing TypeScript types/interfaces | `typescript` |
| Writing Vitest tests | `vitest` |
| Writing unit tests for UI | `vitest` |
---
@@ -2,6 +2,7 @@ import { Skeleton } from "@heroui/skeleton";
import { Suspense } from "react";
import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table";
import { SkeletonBoundary } from "@/components/shadcn";
import { SearchParamsProps } from "@/types";
import { GraphsTabsClient } from "./_components/graphs-tabs-client";
@@ -44,10 +45,21 @@ export const GraphsTabsWrapper = async ({
GRAPH_TABS.map((tab) => {
const Component = GRAPH_COMPONENTS[tab.id];
const fallback = TAB_FALLBACKS[tab.id] ?? <LoadingFallback />;
const content = <Component searchParams={searchParams} />;
if (tab.id === "findings") {
return [
tab.id,
<SkeletonBoundary key={tab.id} fallback={fallback}>
{content}
</SkeletonBoundary>,
];
}
return [
tab.id,
<Suspense key={tab.id} fallback={fallback}>
<Component searchParams={searchParams} />
{content}
</Suspense>,
];
}),
@@ -45,6 +45,7 @@ import {
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
@@ -175,17 +176,19 @@ const PreviewSummarySkeleton = () => (
const PreviewSummary = ({ preview }: { preview: PreviewState }) => {
if (preview.status === "error") {
return (
<Card variant="danger" padding="sm">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<Badge variant="tag">Error</Badge>
</div>
<p className="text-text-error-primary text-sm">{preview.error}</p>
</CardContent>
</Card>
<SkeletonContentReveal>
<Card variant="danger" padding="sm">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<Badge variant="tag">Error</Badge>
</div>
<p className="text-text-error-primary text-sm">{preview.error}</p>
</CardContent>
</Card>
</SkeletonContentReveal>
);
}
@@ -193,16 +196,18 @@ const PreviewSummary = ({ preview }: { preview: PreviewState }) => {
if (!data) return null;
return (
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<p className="text-text-neutral-secondary text-sm">
{getPreviewMessage(data)}
</p>
</CardContent>
</Card>
<SkeletonContentReveal>
<Card variant="inner" padding="sm">
<CardContent className="flex flex-col gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Test result
</span>
<p className="text-text-neutral-secondary text-sm">
{getPreviewMessage(data)}
</p>
</CardContent>
</Card>
</SkeletonContentReveal>
);
};
+3 -3
View File
@@ -1,5 +1,4 @@
import { Info } from "lucide-react";
import { Suspense } from "react";
import {
getComplianceOverviewMetadataInfo,
@@ -16,6 +15,7 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { SkeletonBoundary } from "@/components/shadcn/skeleton/skeleton-boundary";
import { ContentLayout } from "@/components/ui";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import {
@@ -156,7 +156,7 @@ export default async function Compliance({
)}
{/* Row 3: Compliance grid with client-side search */}
<Suspense
<SkeletonBoundary
key={searchParamsKey}
fallback={
<ComplianceOverviewPanel>
@@ -169,7 +169,7 @@ export default async function Compliance({
scanId={selectedScanId}
selectedScan={selectedScanData}
/>
</Suspense>
</SkeletonBoundary>
</>
) : (
<NoScansAvailable />
+3 -4
View File
@@ -1,5 +1,3 @@
import { Suspense } from "react";
import {
adaptFindingGroupsResponse,
getFindingGroups,
@@ -14,6 +12,7 @@ import {
FindingsGroupTable,
SkeletonTableFindings,
} from "@/components/findings/table";
import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
import {
@@ -111,12 +110,12 @@ export default async function Findings({
}
/>
</div>
<Suspense fallback={<SkeletonTableFindings />}>
<SkeletonBoundary fallback={<SkeletonTableFindings />}>
<SSRDataTable
searchParams={resolvedSearchParams}
filters={resolvedFilters}
/>
</Suspense>
</SkeletonBoundary>
</FilterTransitionWrapper>
</ContentLayout>
);
+6 -4
View File
@@ -1,5 +1,4 @@
import Link from "next/link";
import { Suspense } from "react";
import { getInvitations } from "@/actions/invitations/invitation";
import { getRoles } from "@/actions/roles";
@@ -10,7 +9,7 @@ import {
ColumnsInvitation,
SkeletonTableInvitation,
} from "@/components/invitations/table";
import { Button } from "@/components/shadcn";
import { Button, SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { InvitationProps, Role, SearchParamsProps } from "@/types";
@@ -39,9 +38,12 @@ export default async function Invitations({
</Button>
</div>
<Suspense key={searchParamsKey} fallback={<SkeletonTableInvitation />}>
<SkeletonBoundary
key={searchParamsKey}
fallback={<SkeletonTableInvitation />}
>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
</div>
</ContentLayout>
);
@@ -12,6 +12,7 @@ import {
} from "@/actions/processors";
import { Button, Card, Skeleton } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { fontMono } from "@/config/fonts";
@@ -187,111 +188,114 @@ export function AdvancedMutelistForm() {
</div>
</Modal>
<Card variant="base" className="p-6">
<form action={formAction} className="flex flex-col gap-4">
{config && <input type="hidden" name="id" value={config.id} />}
<SkeletonContentReveal>
<Card variant="base" className="p-6">
<form action={formAction} className="flex flex-col gap-4">
{config && <input type="hidden" name="id" value={config.id} />}
<div className="flex flex-col gap-4">
<div>
<h3 className="text-default-700 mb-2 text-lg font-semibold">
Advanced Mutelist Configuration
</h3>
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
<li>
<strong>
This Mutelist configuration will take effect on the next
scan.
</strong>
</li>
<li>
Use this for pattern-based muting with wildcards, regions, and
tags.
</li>
<li>
Learn more about configuring the Mutelist{" "}
<CustomLink
size="sm"
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-mute-findings"
>
here
</CustomLink>
.
</li>
<li>
A default Mutelist is used to exclude certain predefined
resources if no Mutelist is provided.
</li>
</ul>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="configuration"
className="text-default-700 text-sm font-medium"
>
Mutelist Configuration (YAML)
</label>
<div className="flex flex-col gap-4">
<div>
<Textarea
id="configuration"
name="configuration"
placeholder={defaultMutedFindingsConfig}
variant="bordered"
value={configText}
onChange={(e) => handleConfigChange(e.target.value)}
minRows={20}
maxRows={20}
isInvalid={
(!hasUserStartedTyping && !!state?.errors?.configuration) ||
!yamlValidation.isValid
}
errorMessage={
(!hasUserStartedTyping && state?.errors?.configuration) ||
(!yamlValidation.isValid ? yamlValidation.error : "")
}
classNames={{
input: fontMono.className + " text-sm",
base: "min-h-[400px]",
errorMessage: "whitespace-pre-wrap",
}}
/>
{yamlValidation.isValid &&
configText &&
hasUserStartedTyping && (
<div className="text-tiny text-success my-1 flex items-center px-1">
<span>Valid YAML format</span>
</div>
)}
<h3 className="text-default-700 mb-2 text-lg font-semibold">
Advanced Mutelist Configuration
</h3>
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
<li>
<strong>
This Mutelist configuration will take effect on the next
scan.
</strong>
</li>
<li>
Use this for pattern-based muting with wildcards, regions,
and tags.
</li>
<li>
Learn more about configuring the Mutelist{" "}
<CustomLink
size="sm"
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-mute-findings"
>
here
</CustomLink>
.
</li>
<li>
A default Mutelist is used to exclude certain predefined
resources if no Mutelist is provided.
</li>
</ul>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="configuration"
className="text-default-700 text-sm font-medium"
>
Mutelist Configuration (YAML)
</label>
<div>
<Textarea
id="configuration"
name="configuration"
placeholder={defaultMutedFindingsConfig}
variant="bordered"
value={configText}
onChange={(e) => handleConfigChange(e.target.value)}
minRows={20}
maxRows={20}
isInvalid={
(!hasUserStartedTyping &&
!!state?.errors?.configuration) ||
!yamlValidation.isValid
}
errorMessage={
(!hasUserStartedTyping && state?.errors?.configuration) ||
(!yamlValidation.isValid ? yamlValidation.error : "")
}
classNames={{
input: fontMono.className + " text-sm",
base: "min-h-[400px]",
errorMessage: "whitespace-pre-wrap",
}}
/>
{yamlValidation.isValid &&
configText &&
hasUserStartedTyping && (
<div className="text-tiny text-success my-1 flex items-center px-1">
<span>Valid YAML format</span>
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex w-full justify-end gap-4">
{config && (
<div className="flex w-full justify-end gap-4">
{config && (
<Button
type="button"
aria-label="Delete Configuration"
variant="outline"
size="lg"
onClick={() => setShowDeleteConfirmation(true)}
disabled={isPending || isDeleting}
>
<Trash2 className="size-4" />
Delete
</Button>
)}
<Button
type="button"
aria-label="Delete Configuration"
variant="outline"
type="submit"
size="lg"
onClick={() => setShowDeleteConfirmation(true)}
disabled={isPending || isDeleting}
disabled={
isPending || !yamlValidation.isValid || !configText.trim()
}
>
<Trash2 className="size-4" />
Delete
{isPending ? "Saving..." : config ? "Update" : "Save"}
</Button>
)}
<Button
type="submit"
size="lg"
disabled={
isPending || !yamlValidation.isValid || !configText.trim()
}
>
{isPending ? "Saving..." : config ? "Update" : "Save"}
</Button>
</div>
</form>
</Card>
</div>
</form>
</Card>
</SkeletonContentReveal>
</>
);
}
+6 -4
View File
@@ -1,5 +1,4 @@
import { Suspense } from "react";
import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types/components";
@@ -18,9 +17,12 @@ export default async function MutelistPage({
<ContentLayout title="Mutelist" icon="lucide:volume-x">
<MutelistTabs
simpleContent={
<Suspense key={searchParamsKey} fallback={<MuteRulesTableSkeleton />}>
<SkeletonBoundary
key={searchParamsKey}
fallback={<MuteRulesTableSkeleton />}
>
<MuteRulesTable searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
}
/>
</ContentLayout>
+28 -20
View File
@@ -1,7 +1,6 @@
import { Suspense } from "react";
import { getAllProviders } from "@/actions/providers";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
@@ -47,55 +46,64 @@ export default async function Home({
</div>
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
<Suspense fallback={<ThreatScoreSkeleton />}>
<SkeletonBoundary
fallback={<ThreatScoreSkeleton />}
className="w-full lg:max-w-[312px]"
>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
<Suspense fallback={<StatusChartSkeleton />}>
<SkeletonBoundary
fallback={<StatusChartSkeleton />}
className="min-w-[312px] flex-1 md:min-w-[380px]"
>
<CheckFindingsSSR searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
<Suspense fallback={<RiskSeverityChartSkeleton />}>
<SkeletonBoundary
fallback={<RiskSeverityChartSkeleton />}
className="min-w-[312px] flex-1 md:min-w-[380px]"
>
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
</div>
<div className="mt-6">
<Suspense fallback={<ResourcesInventorySkeleton />}>
<SkeletonBoundary fallback={<ResourcesInventorySkeleton />}>
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
</div>
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:flex-wrap sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
<Suspense fallback={<WatchlistCardSkeleton />}>
<SkeletonBoundary fallback={<WatchlistCardSkeleton />}>
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
</div>
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
<Suspense fallback={<WatchlistCardSkeleton />}>
<SkeletonBoundary fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
</div>
</div>
{/* Charts column: Attack Surface on top, Findings Over Time below */}
<div className="flex flex-1 flex-col gap-6">
<Suspense fallback={<AttackSurfaceSkeleton />}>
<SkeletonBoundary fallback={<AttackSurfaceSkeleton />}>
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
</SkeletonBoundary>
<SkeletonBoundary fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
</div>
</div>
<div className="mt-6">
<Suspense fallback={<RiskPipelineViewSkeleton />}>
<SkeletonBoundary fallback={<RiskPipelineViewSkeleton />}>
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
</div>
</ContentLayout>
);
+5 -7
View File
@@ -1,8 +1,6 @@
import { Suspense } from "react";
import { ProvidersAccountsView } from "@/components/providers";
import { SkeletonTableProviders } from "@/components/providers/table";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { Skeleton, SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
import { SearchParamsProps } from "@/types";
@@ -30,20 +28,20 @@ export default async function Providers({
<ProviderPageTabs
activeTab={activeTab}
providersContent={
<Suspense
<SkeletonBoundary
key={`providers-${searchParamsKey}`}
fallback={<ProvidersTableFallback />}
>
<ProvidersTabContent searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
}
providerGroupsContent={
<Suspense
<SkeletonBoundary
key={`groups-${searchParamsKey}`}
fallback={<ProviderGroupsFallback />}
>
<ProviderGroupsContent searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
}
/>
</FilterTransitionWrapper>
+3 -4
View File
@@ -1,5 +1,3 @@
import { Suspense } from "react";
import { getAllProviders } from "@/actions/providers";
import {
getLatestMetadataInfo,
@@ -11,6 +9,7 @@ import {
import { ResourcesFilters } from "@/components/resources/resources-filters";
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
import { ResourcesTableWithSelection } from "@/components/resources/table";
import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
import {
@@ -86,12 +85,12 @@ export default async function Resources({
uniqueGroups={uniqueGroups}
/>
</div>
<Suspense fallback={<SkeletonTableResources />}>
<SkeletonBoundary fallback={<SkeletonTableResources />}>
<SSRDataTable
searchParams={resolvedSearchParams}
initialResource={processedResource}
/>
</Suspense>
</SkeletonBoundary>
</FilterTransitionWrapper>
</ContentLayout>
);
+6 -4
View File
@@ -1,12 +1,11 @@
import Link from "next/link";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles";
import { FilterControls } from "@/components/filters";
import { filterRoles } from "@/components/filters/data-filters";
import { AddIcon } from "@/components/icons";
import { ColumnsRoles, SkeletonTableRoles } from "@/components/roles/table";
import { Button } from "@/components/shadcn";
import { Button, SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { SearchParamsProps } from "@/types";
@@ -34,9 +33,12 @@ export default async function Roles({
</Button>
</div>
<Suspense key={searchParamsKey} fallback={<SkeletonTableRoles />}>
<SkeletonBoundary
key={searchParamsKey}
fallback={<SkeletonTableRoles />}
>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
</div>
</ContentLayout>
);
+3 -4
View File
@@ -1,5 +1,3 @@
import { Suspense } from "react";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { auth } from "@/auth.config";
@@ -12,6 +10,7 @@ import { ScansPageShell } from "@/components/scans/scans-page-shell";
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
import { SkeletonTableScans } from "@/components/scans/table";
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import {
ProviderProps,
@@ -88,7 +87,7 @@ export default async function Scans({
hasManageScansPermission={hasManageScansPermission}
activeScanCount={activeScanCount}
>
<Suspense
<SkeletonBoundary
fallback={
<SkeletonTableScans
tab={getScanJobsTab(resolvedSearchParams.tab)}
@@ -96,7 +95,7 @@ export default async function Scans({
}
>
<SSRDataTableScans searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
</ScansPageShell>
)}
</ContentLayout>
+6 -4
View File
@@ -1,12 +1,11 @@
import Link from "next/link";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles/roles";
import { getCurrentUserTenantRole, getUsers } from "@/actions/users/users";
import { auth } from "@/auth.config";
import { FilterControls } from "@/components/filters";
import { AddIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { Button, SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable } from "@/components/ui/table";
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
@@ -35,9 +34,12 @@ export default async function Users({
</Button>
</div>
<Suspense key={searchParamsKey} fallback={<SkeletonTableUser />}>
<SkeletonBoundary
key={searchParamsKey}
fallback={<SkeletonTableUser />}
>
<SSRDataTable searchParams={resolvedSearchParams} />
</Suspense>
</SkeletonBoundary>
</div>
</ContentLayout>
);
@@ -8,6 +8,7 @@ import {
getStandaloneFindingColumns,
SkeletonTableFindings,
} from "@/components/findings/table";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { Accordion } from "@/components/ui/accordion/Accordion";
import { DataTable } from "@/components/ui/table";
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
@@ -177,7 +178,7 @@ export const ClientAccordionContent = ({
if (findings?.data?.length && findings.data.length > 0) {
return (
<>
<SkeletonContentReveal>
<h4 className="mb-2 text-sm font-medium">Findings</h4>
<DataTable
@@ -186,14 +187,14 @@ export const ClientAccordionContent = ({
metadata={findings?.meta}
disableScroll={true}
/>
</>
</SkeletonContentReveal>
);
}
return (
<div className="mt-3 mb-1 text-sm font-medium text-gray-800 dark:text-gray-200">
<SkeletonContentReveal className="mt-3 mb-1 text-sm font-medium text-gray-800 dark:text-gray-200">
There are no findings for these regions
</div>
</SkeletonContentReveal>
);
};
@@ -7,6 +7,7 @@ import { MuteRuleActionState } from "@/actions/mute-rules/types";
import { Button, Input, Textarea } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { FormButtons } from "@/components/ui/form";
import { Label } from "@/components/ui/form/Label";
import { useMuteRuleAction } from "@/hooks/use-mute-rule-action";
@@ -170,7 +171,7 @@ export function MuteFindingsModal({
</div>
</>
) : preparationError ? (
<>
<SkeletonContentReveal>
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
<p className="text-text-neutral-primary text-sm font-medium">
We couldn&apos;t prepare this mute action.
@@ -190,9 +191,9 @@ export function MuteFindingsModal({
Close
</Button>
</div>
</>
</SkeletonContentReveal>
) : (
<>
<SkeletonContentReveal>
<div className="space-y-4">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
@@ -315,7 +316,7 @@ export function MuteFindingsModal({
submitText="Mute Findings"
isDisabled={isPending}
/>
</>
</SkeletonContentReveal>
)}
</form>
</Modal>
+143 -141
View File
@@ -15,6 +15,7 @@ import {
import { Modal } from "@/components/shadcn/modal";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { useToast } from "@/components/ui";
import { CustomBanner } from "@/components/ui/custom/custom-banner";
import { Form, FormField, FormMessage } from "@/components/ui/form";
@@ -270,151 +271,152 @@ export const SendToJiraModal = ({
</div>
)}
{/* Integration Selection */}
{!isFetchingIntegrations && integrations.length > 1 && (
<FormField
control={form.control}
name="integration"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-integration-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Jira Integration
</label>
<EnhancedMultiSelect
id="jira-integration-select"
options={integrationOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset dependent fields
form.setValue("project", "");
form.setValue("issueType", "");
setFetchedIssueTypes({});
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira integration"
searchable={true}
emptyIndicator="No integrations found."
disabled={isFetchingIntegrations}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
{!isFetchingIntegrations && (
<SkeletonContentReveal className="flex flex-col gap-4">
{/* Integration Selection */}
{integrations.length > 1 && (
<FormField
control={form.control}
name="integration"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-integration-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Jira Integration
</label>
<EnhancedMultiSelect
id="jira-integration-select"
options={integrationOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset dependent fields
form.setValue("project", "");
form.setValue("issueType", "");
setFetchedIssueTypes({});
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira integration"
searchable={true}
emptyIndicator="No integrations found."
disabled={isFetchingIntegrations}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
/>
)}
{/* Project Selection */}
{!isFetchingIntegrations &&
selectedIntegration &&
projectEntries.length > 0 && (
<FormField
control={form.control}
name="project"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-project-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Project
</label>
<EnhancedMultiSelect
id="jira-project-select"
options={projectOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset issue type when project changes
form.setValue("issueType", "");
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira project"
searchable={true}
emptyIndicator="No projects found."
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
{/* Issue Type Selection */}
{selectedProject && (
<FormField
control={form.control}
name="issueType"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-issue-type-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Issue Type
</label>
<EnhancedMultiSelect
id="jira-issue-type-select"
options={issueTypeOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
}}
defaultValue={field.value ? [field.value] : []}
placeholder={
isFetchingIssueTypes
? "Loading issue types..."
: "Select an issue type"
}
searchable={true}
emptyIndicator="No issue types found."
disabled={isFetchingIssueTypes}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
{/* Project Selection */}
{selectedIntegration && projectEntries.length > 0 && (
<FormField
control={form.control}
name="project"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-project-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Project
</label>
<EnhancedMultiSelect
id="jira-project-select"
options={projectOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
// Reset issue type when project changes
form.setValue("issueType", "");
}}
defaultValue={field.value ? [field.value] : []}
placeholder="Select a Jira project"
searchable={true}
emptyIndicator="No projects found."
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
/>
)}
{/* No integrations or none connected message */}
{!isFetchingIntegrations &&
(integrations.length === 0 || !hasConnectedIntegration) ? (
<CustomBanner
title="Jira integration is not available"
message="Please add or connect an integration first"
buttonLabel="Configure"
buttonLink="/integrations/jira"
/>
) : (
<FormButtons
setIsOpen={setOpenForFormButtons}
onCancel={() => onOpenChange(false)}
submitText="Send to Jira"
cancelText="Cancel"
loadingText="Sending..."
isDisabled={
!form.formState.isValid ||
form.formState.isSubmitting ||
isFetchingIntegrations ||
isFetchingIssueTypes ||
integrations.length === 0 ||
!hasConnectedIntegration
}
rightIcon={<Send size={20} />}
/>
{/* Issue Type Selection */}
{selectedProject && (
<FormField
control={form.control}
name="issueType"
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<label
htmlFor="jira-issue-type-select"
className="text-text-neutral-secondary text-xs font-light tracking-tight"
>
Issue Type
</label>
<EnhancedMultiSelect
id="jira-issue-type-select"
options={issueTypeOptions}
onValueChange={(values) => {
const selectedValue = values.at(-1) ?? "";
field.onChange(selectedValue);
}}
defaultValue={field.value ? [field.value] : []}
placeholder={
isFetchingIssueTypes
? "Loading issue types..."
: "Select an issue type"
}
searchable={true}
emptyIndicator="No issue types found."
disabled={isFetchingIssueTypes}
hideSelectAll={true}
maxCount={1}
closeOnSelect={true}
resetOnDefaultValueChange={true}
/>
<FormMessage className="text-text-error text-xs" />
</div>
)}
/>
)}
{/* No integrations or none connected message */}
{integrations.length === 0 || !hasConnectedIntegration ? (
<CustomBanner
title="Jira integration is not available"
message="Please add or connect an integration first"
buttonLabel="Configure"
buttonLink="/integrations/jira"
/>
) : (
<FormButtons
setIsOpen={setOpenForFormButtons}
onCancel={() => onOpenChange(false)}
submitText="Send to Jira"
cancelText="Cancel"
loadingText="Sending..."
isDisabled={
!form.formState.isValid ||
form.formState.isSubmitting ||
isFetchingIntegrations ||
isFetchingIssueTypes ||
integrations.length === 0 ||
!hasConnectedIntegration
}
rightIcon={<Send size={20} />}
/>
)}
</SkeletonContentReveal>
)}
</form>
</Form>
@@ -5,11 +5,12 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { AnimatePresence, motion } from "framer-motion";
import { motion } from "framer-motion";
import { ChevronsDown } from "lucide-react";
import { useImperativeHandle, useRef } from "react";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { TableCell, TableRow } from "@/components/ui/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
@@ -213,109 +214,120 @@ export function InlineResourceContainer({
onMuteComplete: handleMuteComplete,
}}
>
<tr>
<motion.tr
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<td colSpan={columnCount} className="p-0">
<AnimatePresence initial>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="relative">
<div
ref={combinedScrollRef}
className="max-h-[440px] overflow-y-auto pl-6"
>
{/* Resource rows or skeleton placeholder */}
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="relative">
<div
ref={combinedScrollRef}
className="max-h-[440px] overflow-y-auto pl-6"
>
{/* Resource rows or skeleton placeholder */}
{isLoading && rows.length === 0 ? (
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<tbody>
{isLoading && rows.length === 0 ? (
Array.from({ length: skeletonRowCount }).map((_, i) => (
<ResourceSkeletonRow
key={i}
isEmptyStateSized={filteredResourceCount === 0}
/>
))
) : rows.length > 0 ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={(e) => {
// Don't open drawer if clicking interactive elements
// (links, buttons, checkboxes, dropdown items)
const target = e.target as HTMLElement;
if (
target.closest(
"a, button, input, [role=menuitem]",
)
)
return;
drawer.openDrawer(row.index);
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{getFindingGroupEmptyStateMessage(group, filters)}
</TableCell>
</TableRow>
)}
{Array.from({ length: skeletonRowCount }).map((_, i) => (
<ResourceSkeletonRow
key={i}
isEmptyStateSized={filteredResourceCount === 0}
/>
))}
</tbody>
</table>
) : (
<SkeletonContentReveal>
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<tbody>
{rows.length > 0 ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={(e) => {
// Don't open drawer if clicking interactive elements
// (links, buttons, checkboxes, dropdown items)
const target = e.target as HTMLElement;
if (
target.closest(
"a, button, input, [role=menuitem]",
)
)
return;
drawer.openDrawer(row.index);
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{getFindingGroupEmptyStateMessage(group, filters)}
</TableCell>
</TableRow>
)}
</tbody>
</table>
</SkeletonContentReveal>
)}
{/* Loading state for infinite scroll (subsequent pages only) */}
{isLoading && rows.length > 0 && (
<LoadingState label="Loading resources..." />
)}
{/* Loading state for infinite scroll (subsequent pages only) */}
{isLoading && rows.length > 0 && (
<LoadingState label="Loading resources..." />
)}
{/* Sentinel for scroll hint detection */}
<div
ref={scrollHintSentinelRef}
aria-hidden
className="h-px shrink-0"
/>
{/* Sentinel for scroll hint detection */}
<div
ref={scrollHintSentinelRef}
aria-hidden
className="h-px shrink-0"
/>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
</div>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
</div>
{/* Gradients rendered after scroll container so they paint on top */}
<div className="from-bg-neutral-secondary pointer-events-none absolute top-0 right-0 left-6 z-20 h-6 bg-gradient-to-b to-transparent" />
<div className="from-bg-neutral-secondary pointer-events-none absolute right-0 bottom-0 left-6 z-20 h-6 bg-gradient-to-t to-transparent" />
{/* Gradients rendered after scroll container so they paint on top */}
<div className="from-bg-neutral-secondary pointer-events-none absolute top-0 right-0 left-6 z-20 h-6 bg-gradient-to-b to-transparent" />
<div className="from-bg-neutral-secondary pointer-events-none absolute right-0 bottom-0 left-6 z-20 h-6 bg-gradient-to-t to-transparent" />
{/* Scroll hint */}
{showScrollHint && (
<div className="pointer-events-none absolute right-0 bottom-0 left-6 z-30">
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary animate-bounce rounded-full px-3 py-1 text-xs shadow-md">
<ChevronsDown className="inline size-3.5" /> Scroll for
more
</div>
{/* Scroll hint */}
{showScrollHint && (
<div className="pointer-events-none absolute right-0 bottom-0 left-6 z-30">
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary animate-bounce rounded-full px-3 py-1 text-xs shadow-md">
<ChevronsDown className="inline size-3.5" /> Scroll for
more
</div>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
)}
</div>
</motion.div>
</td>
</tr>
</motion.tr>
<ResourceDetailDrawer
open={drawer.isOpen}
@@ -37,6 +37,7 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import {
Tooltip,
@@ -494,7 +495,7 @@ export function ResourceDetailDrawerContent({
};
return (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<SkeletonContentReveal className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
{/* Mute modal — rendered outside drawer content to avoid overlay conflicts */}
{f && !f.isMuted && (
<MuteFindingsModal
@@ -1339,7 +1340,7 @@ export function ResourceDetailDrawerContent({
Analyze This Finding With Lighthouse AI
</a>
)}
</div>
</SkeletonContentReveal>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[background-color,border-color,color,box-shadow] duration-200 ease-out motion-reduce:transition-none overflow-hidden",
{
variants: {
variant: {
@@ -32,4 +32,53 @@ describe("Button", () => {
"text-xs",
);
});
it("applies the shared press and reduced-motion contract to button-like variants", () => {
// Given
render(<Button>Start scan</Button>);
// When
const button = screen.getByRole("button", { name: "Start scan" });
// Then
expect(button).toHaveClass(
"transition-[background-color,border-color,color,box-shadow,transform,scale]",
"duration-150",
"ease-out",
"active:scale-[0.98]",
"motion-reduce:active:scale-100",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
expect(button).not.toHaveClass("transition-all");
});
it("keeps link buttons from scaling on press", () => {
// Given
render(<Button variant="link">Open details</Button>);
// When
const button = screen.getByRole("button", { name: "Open details" });
// Then
expect(button).toHaveClass("active:scale-100");
expect(button).not.toHaveClass("active:scale-[0.98]");
});
it("keeps menu buttons on the shared targeted transition recipe", () => {
// Given
render(<Button variant="menu">Open menu</Button>);
// When
const button = screen.getByRole("button", { name: "Open menu" });
// Then
expect(button).toHaveClass(
"transition-[background-color,border-color,color,box-shadow,transform,scale]",
"duration-200",
"active:scale-[0.98]",
"motion-reduce:active:scale-100",
);
expect(button).not.toHaveClass("transition-all");
});
});
+5 -5
View File
@@ -5,7 +5,7 @@ import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-sm font-medium transition-all disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-sm font-medium transition-[background-color,border-color,color,box-shadow,transform,scale] duration-150 ease-out active:scale-[0.98] disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary motion-reduce:active:scale-100 motion-reduce:transform-none motion-reduce:transition-none outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -21,13 +21,13 @@ const buttonVariants = cva(
"border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50",
ghost:
"border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover active:scale-100 disabled:bg-transparent",
// Menu variant like secondary but more padding and the back is almost transparent
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 focus-visible:ring-button-primary/50 duration-200",
"menu-active":
"backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
"backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 focus-visible:ring-button-primary/50 duration-200",
"menu-inactive":
"text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-border-neutral-secondary/50 transition-all duration-200",
"text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 focus-visible:ring-border-neutral-secondary/50 duration-200",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { Checkbox } from "./checkbox";
function ControlledCheckbox() {
const [checked, setChecked] = useState(false);
return (
<Checkbox
aria-label="Select provider"
checked={checked}
onCheckedChange={(value) => setChecked(value === true)}
/>
);
}
describe("Checkbox", () => {
it("animates the background and check mark as one state change", async () => {
// Given - A controlled checkbox in the unchecked state
const user = userEvent.setup();
render(<ControlledCheckbox />);
const checkbox = screen.getByRole("checkbox", { name: /select provider/i });
const indicator = checkbox.querySelector(
"[data-slot='checkbox-indicator']",
);
// When - The user checks the checkbox
await user.click(checkbox);
// Then - The background and check mark transitions use the same timing
expect(checkbox).toHaveClass(
"transition-colors",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
expect(indicator).toHaveClass(
"transition-[opacity,transform]",
"duration-200",
"ease-out",
"data-[state=checked]:scale-100",
"data-[state=checked]:opacity-100",
"data-[state=unchecked]:scale-75",
"data-[state=unchecked]:opacity-0",
"motion-reduce:transition-none",
);
});
});
+3 -2
View File
@@ -41,7 +41,7 @@ function Checkbox({
checked={indeterminate ? "indeterminate" : checked}
className={cn(
// Base styles
"peer shrink-0 rounded-sm border transition-all outline-none",
"peer shrink-0 rounded-sm border transition-colors duration-200 ease-out outline-none motion-reduce:transition-none",
sizeStyles.root,
// Default state
"bg-bg-input-primary border-border-input-primary shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]",
@@ -58,8 +58,9 @@ function Checkbox({
{...props}
>
<CheckboxPrimitive.Indicator
forceMount
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
className="grid place-content-center text-current transition-[opacity,transform] duration-200 ease-out data-[state=checked]:scale-100 data-[state=checked]:opacity-100 data-[state=indeterminate]:scale-100 data-[state=indeterminate]:opacity-100 data-[state=unchecked]:scale-75 data-[state=unchecked]:opacity-0 motion-reduce:scale-100 motion-reduce:transition-none"
>
{indeterminate || checked === "indeterminate" ? (
<MinusIcon className={sizeStyles.icon} />
+59
View File
@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "./collapsible";
describe("Collapsible", () => {
it("uses an intentional open and close motion contract", () => {
// Given
render(
<Collapsible open>
<CollapsibleTrigger>Toggle details</CollapsibleTrigger>
<CollapsibleContent>Expandable content</CollapsibleContent>
</Collapsible>,
);
// When
const content = screen.getByText("Expandable content");
// Then
expect(content).toHaveAttribute("data-slot", "collapsible-content");
expect(content).toHaveClass(
"overflow-hidden",
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:slide-in-from-top-1",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:slide-out-to-top-1",
"data-[state=closed]:duration-150",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy motion for reduced-motion users", () => {
// Given
render(
<Collapsible open>
<CollapsibleTrigger>Toggle details</CollapsibleTrigger>
<CollapsibleContent>Expandable content</CollapsibleContent>
</Collapsible>,
);
// When
const content = screen.getByText("Expandable content");
// Then
expect(content).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+11
View File
@@ -2,6 +2,8 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { cn } from "@/lib/utils";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
@@ -20,11 +22,20 @@ function CollapsibleTrigger({
}
function CollapsibleContent({
className,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
className={cn(
"overflow-hidden duration-200 ease-out",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1",
"data-[state=closed]:duration-150 data-[state=closed]:ease-in",
"motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
/>
);
@@ -0,0 +1,94 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { Combobox } from "./combobox";
const options = [
{ value: "aws", label: "AWS" },
{ value: "azure", label: "Azure" },
{ value: "gcp", label: "GCP" },
];
beforeAll(() => {
global.ResizeObserver = class ResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
} as unknown as typeof ResizeObserver;
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
});
describe("Combobox", () => {
it("renders a selectable combobox trigger", () => {
// Given
render(<Combobox options={options} placeholder="Select provider" />);
// When
const trigger = screen.getByRole("combobox", { name: /select provider/i });
// Then
expect(trigger).toBeVisible();
expect(trigger).toHaveAttribute("aria-expanded", "false");
});
it("uses visible trigger and chevron open-state motion", () => {
// Given
render(<Combobox options={options} placeholder="Select provider" />);
// When
const trigger = screen.getByRole("combobox", { name: /select provider/i });
const icon = trigger.querySelector("svg");
// Then
expect(trigger).toHaveClass(
"group",
"transition-[background-color,border-color,color,box-shadow]",
);
expect(icon).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"group-aria-expanded:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("opens with the shared Popover content motion contract", async () => {
// Given
const user = userEvent.setup();
render(<Combobox options={options} placeholder="Select provider" />);
// When
await user.click(
screen.getByRole("combobox", { name: /select provider/i }),
);
const content = document.querySelector("[data-slot='popover-content']");
// Then
expect(content).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+3 -1
View File
@@ -113,8 +113,10 @@ export function Combobox({
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={selectedOption ? selectedOption.label : placeholder}
disabled={disabled}
className={cn(
"group transition-[background-color,border-color,color,box-shadow]",
comboboxTriggerVariants({ variant }),
triggerClassName,
className,
@@ -123,7 +125,7 @@ export function Combobox({
<span className="truncate">
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none" />
</Button>
</PopoverTrigger>
<PopoverContent
+84
View File
@@ -0,0 +1,84 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from "./dialog";
function renderOpenDialog() {
return render(
<Dialog open>
<DialogTrigger>Open modal</DialogTrigger>
<DialogContent>
<DialogTitle>Launch scan</DialogTitle>
<DialogDescription>Configure scan settings</DialogDescription>
</DialogContent>
</Dialog>,
);
}
describe("Dialog", () => {
it("renders controlled content through the Radix Dialog API", () => {
// Given
renderOpenDialog();
// When
const dialog = screen.getByRole("dialog", { name: "Launch scan" });
// Then
expect(dialog).toBeVisible();
expect(dialog).toHaveAttribute("data-slot", "dialog-content");
expect(screen.getByText("Configure scan settings")).toBeVisible();
});
it("uses an intentional overlay motion contract", () => {
// Given
renderOpenDialog();
// When
const overlay = document.querySelector("[data-slot='dialog-overlay']");
// Then
expect(overlay).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transition-none",
);
});
it("uses an intentional content motion contract", () => {
// Given
renderOpenDialog();
// When
const dialog = screen.getByRole("dialog", { name: "Launch scan" });
// Then
expect(dialog).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+2 -2
View File
@@ -37,7 +37,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
@@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none sm:max-w-lg",
className,
)}
onClick={(e) => e.stopPropagation()}
+88
View File
@@ -0,0 +1,88 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerTitle,
DrawerTrigger,
} from "./drawer";
function renderOpenDrawer() {
return render(
<Drawer open>
<DrawerTrigger>Open drawer</DrawerTrigger>
<DrawerContent>
<DrawerTitle>Resource details</DrawerTitle>
<DrawerDescription>Review resource metadata</DrawerDescription>
</DrawerContent>
</Drawer>,
);
}
describe("Drawer", () => {
it("renders controlled content through the Vaul Drawer API", () => {
// Given
renderOpenDrawer();
// When
const drawer = screen.getByRole("dialog", { name: "Resource details" });
// Then
expect(drawer).toBeVisible();
expect(drawer).toHaveAttribute("data-slot", "drawer-content");
expect(screen.getByText("Review resource metadata")).toBeVisible();
});
it("uses an intentional overlay motion contract", () => {
// Given
renderOpenDrawer();
// When
const overlay = document.querySelector("[data-slot='drawer-overlay']");
// Then
expect(overlay).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transition-none",
);
});
it("uses direction-aware drawer content motion", () => {
// Given
renderOpenDrawer();
// When
const drawer = screen.getByRole("dialog", { name: "Resource details" });
// Then
expect(drawer).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"data-[vaul-drawer-direction=bottom]:slide-in-from-bottom-full",
"data-[vaul-drawer-direction=bottom]:data-[state=closed]:slide-out-to-bottom-full",
"data-[vaul-drawer-direction=top]:slide-in-from-top-full",
"data-[vaul-drawer-direction=top]:data-[state=closed]:slide-out-to-top-full",
"data-[vaul-drawer-direction=right]:slide-in-from-right-full",
"data-[vaul-drawer-direction=right]:data-[state=closed]:slide-out-to-right-full",
"data-[vaul-drawer-direction=left]:slide-in-from-left-full",
"data-[vaul-drawer-direction=left]:data-[state=closed]:slide-out-to-left-full",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+6 -6
View File
@@ -35,7 +35,7 @@ function DrawerOverlay({
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
@@ -54,11 +54,11 @@ function DrawerContent({
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:border-l-border-neutral-secondary data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:border-l",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:border-r",
"group/drawer-content bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex h-auto flex-col duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
"data-[vaul-drawer-direction=top]:slide-in-from-top-full data-[vaul-drawer-direction=top]:data-[state=closed]:slide-out-to-top-full data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:slide-in-from-bottom-full data-[vaul-drawer-direction=bottom]:data-[state=closed]:slide-out-to-bottom-full data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:border-l-border-neutral-secondary data-[vaul-drawer-direction=right]:slide-in-from-right-full data-[vaul-drawer-direction=right]:data-[state=closed]:slide-out-to-right-full data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:border-l",
"data-[vaul-drawer-direction=left]:slide-in-from-left-full data-[vaul-drawer-direction=left]:data-[state=closed]:slide-out-to-left-full data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:border-r",
className,
)}
{...props}
@@ -0,0 +1,114 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./dropdown";
function renderActionsDropdown() {
return render(
<DropdownMenu open>
<DropdownMenuTrigger>Open actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
);
}
describe("DropdownMenu", () => {
it("renders open menu content through the Radix DropdownMenu API", () => {
// Given
renderActionsDropdown();
// When
const menu = screen.getByRole("menu");
// Then
expect(menu).toBeVisible();
expect(menu).toHaveAttribute("data-slot", "dropdown-menu-content");
expect(screen.getByRole("menuitem", { name: "Edit" })).toBeVisible();
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeVisible();
});
it("uses an intentional open and close motion contract", () => {
// Given
renderActionsDropdown();
// When
const menu = screen.getByRole("menu");
// Then
expect(menu).toHaveClass(
"origin-(--radix-dropdown-menu-content-transform-origin)",
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy menu motion for reduced-motion users", () => {
// Given
renderActionsDropdown();
// When
const menu = screen.getByRole("menu");
// Then
expect(menu).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
it("applies the same motion contract to submenu content", () => {
// Given
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>More actions</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Archive</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>,
);
// When
const submenuContent = screen
.getByRole("menuitem", { name: "Archive" })
.closest("[data-slot='dropdown-menu-sub-content']");
// Then
expect(submenuContent).toHaveClass(
"origin-(--radix-dropdown-menu-content-transform-origin)",
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+2 -2
View File
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
@@ -227,7 +227,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
@@ -0,0 +1,42 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { FileUploadDropzone } from "./file-upload-dropzone";
describe("FileUploadDropzone", () => {
it("animates drag feedback and selected file content", async () => {
// Given - A dropzone without a selected file
const user = userEvent.setup();
const onFileSelect = vi.fn();
render(<FileUploadDropzone onFileSelect={onFileSelect} />);
// When - The dropzone renders
const dropzone = screen.getByText(/drag and drop/i).closest("label");
const input = screen.getByLabelText(/drag and drop/i, {
selector: "input",
});
// Then - Drag feedback and internal content have visible motion contracts
expect(dropzone).toHaveClass(
"transition-[background-color,border-color,box-shadow,transform]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(dropzone?.querySelector("svg")).toHaveClass(
"transition-transform",
"duration-150",
"ease-out",
"group-hover:-translate-y-0.5",
"motion-reduce:transform-none",
);
await user.upload(
input,
new File(["prowler"], "evidence.json", { type: "application/json" }),
);
expect(onFileSelect).toHaveBeenCalledWith(expect.any(File));
});
});
@@ -24,7 +24,9 @@ export function FileUploadDropzone({
title = "Drag and drop your file here",
emptyDescription = "or",
selectText = "Select File",
icon = <FileUp className="text-text-neutral-secondary size-6" />,
icon = (
<FileUp className="text-text-neutral-secondary size-6 transition-transform duration-150 ease-out group-hover:-translate-y-0.5 motion-reduce:transform-none motion-reduce:transition-none" />
),
}: FileUploadDropzoneProps) {
const inputId = useId();
const [isDragging, setIsDragging] = useState(false);
@@ -45,23 +47,23 @@ export function FileUploadDropzone({
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={cn(
"border-border-neutral-tertiary bg-bg-neutral-primary hover:bg-bg-neutral-tertiary flex min-h-[132px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-colors",
"border-border-neutral-tertiary bg-bg-neutral-primary hover:bg-bg-neutral-tertiary group flex min-h-[132px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-[background-color,border-color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none",
isDragging &&
"border-border-input-primary-press bg-bg-neutral-tertiary",
"border-border-input-primary-press bg-bg-neutral-tertiary scale-[1.01] shadow-sm motion-reduce:scale-100",
className,
)}
>
{icon}
<span className="text-text-neutral-primary text-sm font-medium">
<span className="text-text-neutral-primary text-sm font-medium transition-colors duration-150 ease-out motion-reduce:transition-none">
{file ? file.name : title}
</span>
<span className="text-text-neutral-secondary text-xs">
<span className="text-text-neutral-secondary text-xs transition-colors duration-150 ease-out motion-reduce:transition-none">
{file
? `${Math.ceil(file.size / 1024).toLocaleString()} KB`
: emptyDescription}
</span>
{!file && (
<span className="text-button-tertiary text-sm font-medium">
<span className="text-button-tertiary text-sm font-medium transition-colors duration-150 ease-out motion-reduce:transition-none">
{selectText}
</span>
)}
+2
View File
@@ -20,6 +20,8 @@ export * from "./select/multiselect";
export * from "./select/select";
export * from "./separator/separator";
export * from "./skeleton/skeleton";
export * from "./skeleton/skeleton-boundary";
export * from "./skeleton/skeleton-content-reveal";
export * from "./tabs/generic-tabs";
export * from "./tabs/tabs";
export * from "./textarea/textarea";
+22
View File
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Input } from "./input";
describe("Input", () => {
it("uses visible hover and focus microinteraction timing", () => {
// Given - A standard text input
render(<Input aria-label="Alias" />);
// When - The input renders
const input = screen.getByRole("textbox", { name: /alias/i });
// Then - The focus/hover state changes are intentionally timed
expect(input).toHaveClass(
"transition-[background-color,border-color,box-shadow,color]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ import { ComponentProps, forwardRef } from "react";
import { cn } from "@/lib/utils";
const inputVariants = cva(
"flex w-full rounded-lg border text-sm transition-all outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
"flex w-full rounded-lg border text-sm transition-[background-color,border-color,box-shadow,color] duration-150 ease-out outline-none motion-reduce:transition-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
+87
View File
@@ -0,0 +1,87 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
describe("Popover", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("renders controlled content through the Radix Popover API", () => {
// Given
const portalContainer = document.createElement("div");
document.body.appendChild(portalContainer);
// When
render(
<Popover open>
<PopoverTrigger>Open filters</PopoverTrigger>
<PopoverContent container={portalContainer}>
Filter content
</PopoverContent>
</Popover>,
);
// Then
expect(screen.getByText("Filter content")).toBeVisible();
expect(screen.getByText("Filter content")).toHaveAttribute(
"data-slot",
"popover-content",
);
});
it("uses an intentional open and close motion contract", () => {
// Given
const portalContainer = document.createElement("div");
document.body.appendChild(portalContainer);
// When
render(
<Popover open>
<PopoverTrigger>Open filters</PopoverTrigger>
<PopoverContent container={portalContainer}>
Filter content
</PopoverContent>
</Popover>,
);
// Then
expect(screen.getByText("Filter content")).toHaveClass(
"origin-(--radix-popover-content-transform-origin)",
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy motion for reduced-motion users", () => {
// Given
const portalContainer = document.createElement("div");
document.body.appendChild(portalContainer);
// When
render(
<Popover open>
<PopoverTrigger>Open filters</PopoverTrigger>
<PopoverContent container={portalContainer}>
Filter content
</PopoverContent>
</Popover>,
);
// Then
expect(screen.getByText("Filter content")).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+1 -1
View File
@@ -32,7 +32,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 pointer-events-auto z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 pointer-events-auto z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
{...props}
+30
View File
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Progress } from "./progress";
describe("Progress", () => {
it("animates progress value changes with a transform-only transition", () => {
// Given
render(<Progress aria-label="Scan progress" value={40} />);
// When
const root = screen.getByRole("progressbar", { name: /scan progress/i });
const indicator = root.querySelector("[data-slot='progress-indicator']");
// Then
expect(root).toHaveClass(
"transition-colors",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
expect(indicator).toHaveClass(
"transition-transform",
"duration-300",
"ease-out",
"motion-reduce:transition-none",
);
expect(indicator).toHaveStyle({ transform: "translateX(-60%)" });
});
});
+2 -2
View File
@@ -22,7 +22,7 @@ function Progress({
data-slot="progress"
value={normalizedValue}
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary relative h-2 w-full overflow-hidden rounded-full border",
"border-border-neutral-secondary bg-bg-neutral-secondary relative h-2 w-full overflow-hidden rounded-full border transition-colors duration-200 ease-out motion-reduce:transition-none",
className,
)}
{...props}
@@ -30,7 +30,7 @@ function Progress({
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn(
"bg-button-primary h-full w-full flex-1 transition-all",
"bg-button-primary h-full w-full flex-1 transition-transform duration-300 ease-out motion-reduce:transition-none",
indicatorClassName,
)}
style={{ transform: `translateX(-${100 - normalizedValue}%)` }}
@@ -0,0 +1,45 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { RadioGroup, RadioGroupItem } from "./radio-group";
describe("RadioGroup", () => {
it("animates item state and indicator entry", async () => {
// Given - A controlled radio group
const user = userEvent.setup();
const onValueChange = vi.fn();
render(
<RadioGroup value="aws" onValueChange={onValueChange}>
<RadioGroupItem value="aws" aria-label="AWS" />
<RadioGroupItem value="azure" aria-label="Azure" />
</RadioGroup>,
);
// When - The user selects another radio option
const azure = screen.getByRole("radio", { name: /azure/i });
await user.click(azure);
const indicator = azure.querySelector(
"[data-slot='radio-group-indicator']",
);
// Then - The item and dot use synchronized visual feedback
expect(azure).toHaveClass(
"transition-[background-color,border-color,box-shadow]",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
expect(indicator).toHaveClass(
"transition-[opacity,transform]",
"duration-200",
"ease-out",
"data-[state=checked]:scale-100",
"data-[state=checked]:opacity-100",
"data-[state=unchecked]:scale-75",
"data-[state=unchecked]:opacity-0",
"motion-reduce:transition-none",
);
expect(onValueChange).toHaveBeenCalledWith("azure");
});
});
@@ -25,7 +25,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-border-input-primary aspect-square size-4 shrink-0 rounded-full border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)] transition-all outline-none",
"border-border-input-primary aspect-square size-4 shrink-0 rounded-full border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)] transition-[background-color,border-color,box-shadow] duration-200 ease-out outline-none motion-reduce:transition-none",
"focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press/50 focus-visible:ring-2",
"data-[state=checked]:border-button-primary",
"disabled:cursor-not-allowed disabled:opacity-40",
@@ -34,8 +34,9 @@ function RadioGroupItem({
{...props}
>
<RadioGroupPrimitive.Indicator
forceMount
data-slot="radio-group-indicator"
className="grid place-content-center"
className="grid place-content-center transition-[opacity,transform] duration-200 ease-out data-[state=checked]:scale-100 data-[state=checked]:opacity-100 data-[state=unchecked]:scale-75 data-[state=unchecked]:opacity-0 motion-reduce:scale-100 motion-reduce:transition-none"
>
<span className="bg-button-primary size-2 rounded-full" />
</RadioGroupPrimitive.Indicator>
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SearchInput } from "./search-input";
describe("SearchInput", () => {
it("animates input focus, icon color, and clear button entry", () => {
// Given - A search input with a clear action
render(
<SearchInput
aria-label="Search findings"
value="cloudflare"
readOnly
onClear={vi.fn()}
/>,
);
// When - The search field has a value
const input = screen.getByRole("textbox", { name: /search findings/i });
const clearButton = screen.getByRole("button", { name: /clear search/i });
const searchIcon = input.parentElement?.querySelector("svg");
// Then - Search-specific affordances have visible motion
expect(input).toHaveClass(
"transition-[background-color,border-color,box-shadow,color]",
"duration-250",
"ease-out",
"motion-reduce:transition-none",
);
expect(searchIcon).toHaveClass(
"transition-colors",
"duration-250",
"ease-out",
"motion-reduce:transition-none",
);
expect(clearButton).toHaveAttribute("data-slot", "search-input-clear");
expect(clearButton).toHaveClass(
"transition-colors",
"duration-250",
"ease-out",
"motion-reduce:transition-none",
);
});
});
@@ -1,6 +1,7 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { AnimatePresence, motion } from "framer-motion";
import { SearchIcon, XCircle } from "lucide-react";
import { ComponentProps, forwardRef } from "react";
@@ -20,7 +21,7 @@ const searchInputWrapperVariants = cva("relative flex items-center w-full", {
});
const searchInputVariants = cva(
"flex w-full rounded-lg border text-sm transition-all outline-none placeholder:text-text-neutral-tertiary disabled:cursor-not-allowed disabled:opacity-50",
"flex w-full rounded-lg border text-sm transition-[background-color,border-color,box-shadow,color] duration-250 ease-out outline-none motion-reduce:transition-none placeholder:text-text-neutral-tertiary disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
@@ -89,7 +90,7 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
<SearchIcon
size={iconSize}
className={cn(
"text-text-neutral-tertiary pointer-events-none absolute",
"text-text-neutral-tertiary pointer-events-none absolute transition-colors duration-250 ease-out motion-reduce:transition-none",
iconPosition,
)}
/>
@@ -102,19 +103,27 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
className={cn(searchInputVariants({ variant, size, className }))}
{...props}
/>
{hasValue && onClear && (
<button
type="button"
aria-label="Clear search"
onClick={onClear}
className={cn(
"text-text-neutral-tertiary hover:text-text-neutral-primary absolute transition-colors focus:outline-none",
clearButtonPosition,
)}
>
<XCircle size={iconSize} />
</button>
)}
<AnimatePresence initial={false}>
{hasValue && onClear && (
<motion.button
key="clear-search"
type="button"
data-slot="search-input-clear"
aria-label="Clear search"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.25, ease: "easeOut" }}
onClick={onClear}
className={cn(
"text-text-neutral-tertiary hover:text-text-neutral-primary absolute transition-colors duration-250 ease-out focus:outline-none motion-reduce:transition-none",
clearButtonPosition,
)}
>
<XCircle size={iconSize} />
</motion.button>
)}
</AnimatePresence>
</div>
);
},
@@ -0,0 +1,138 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { EnhancedMultiSelect } from "./enhanced-multi-select";
const options = [
{ value: "aws-prod", label: "Production AWS" },
{ value: "azure-dev", label: "Development Azure" },
];
beforeAll(() => {
global.ResizeObserver = class ResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
} as unknown as typeof ResizeObserver;
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
describe("EnhancedMultiSelect", () => {
it("uses visible trigger and chevron open-state motion", () => {
render(
<EnhancedMultiSelect
options={options}
onValueChange={() => {}}
placeholder="Select providers"
aria-label="Select providers"
/>,
);
const trigger = screen.getByRole("combobox", { name: /select providers/i });
const icon = trigger.querySelector("svg");
expect(trigger).toHaveClass(
"group",
"transition-[background-color,border-color,color,box-shadow]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(icon).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"group-aria-expanded:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("animates item selection feedback and checkbox visibility", async () => {
const user = userEvent.setup();
render(
<EnhancedMultiSelect
options={options}
onValueChange={() => {}}
placeholder="Select providers"
aria-label="Select providers"
/>,
);
await user.click(
screen.getByRole("combobox", { name: /select providers/i }),
);
const option = screen.getByRole("option", { name: /production aws/i });
const checkbox = option.querySelector("[data-slot='checkbox']");
const checkboxIndicator = checkbox?.querySelector(
"[data-slot='checkbox-indicator']",
);
expect(option).toHaveClass(
"transition-colors",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(checkbox).toHaveClass(
"transition-colors",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
expect(checkboxIndicator).toHaveClass(
"transition-[opacity,transform]",
"duration-200",
"ease-out",
"data-[state=checked]:scale-100",
"data-[state=checked]:opacity-100",
"data-[state=unchecked]:scale-75",
"data-[state=unchecked]:opacity-0",
"motion-reduce:transition-none",
);
});
it("animates selected pills when values are added to the trigger", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
render(
<EnhancedMultiSelect
options={options}
onValueChange={onValueChange}
placeholder="Select providers"
aria-label="Select providers"
/>,
);
await user.click(
screen.getByRole("combobox", { name: /select providers/i }),
);
await user.click(screen.getByRole("option", { name: /production aws/i }));
const pill = within(
screen.getByRole("combobox", { name: /select providers/i }),
)
.getByText("Production AWS")
.closest("[data-slot='enhanced-multiselect-pill']");
expect(onValueChange).toHaveBeenCalledWith(["aws-prod"]);
expect(pill).toHaveClass(
"animate-in",
"fade-in-0",
"zoom-in-95",
"duration-150",
"ease-out",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
@@ -183,7 +183,7 @@ export function EnhancedMultiSelect({
aria-controls={open ? listboxId : undefined}
aria-label={ariaLabel}
className={cn(
"border-border-input-primary bg-bg-input-primary text-text-neutral-primary data-[placeholder]:text-text-neutral-tertiary [&_svg:not([class*='text-'])]:text-text-neutral-tertiary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-input-primary active:bg-bg-input-primary focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-auto min-h-12 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 [&_svg]:pointer-events-auto",
"group border-border-input-primary bg-bg-input-primary text-text-neutral-primary data-[placeholder]:text-text-neutral-tertiary [&_svg:not([class*='text-'])]:text-text-neutral-tertiary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-input-primary active:bg-bg-input-primary focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-auto min-h-12 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out outline-none focus-visible:ring-1 focus-visible:ring-offset-1 motion-reduce:transition-none [&_svg]:pointer-events-auto",
disabled && "cursor-not-allowed opacity-50",
className,
)}
@@ -200,7 +200,8 @@ export function EnhancedMultiSelect({
<Badge
key={value}
variant="tag"
className="m-1 cursor-default [&>svg]:pointer-events-auto"
data-slot="enhanced-multiselect-pill"
className="animate-in fade-in-0 zoom-in-95 m-1 cursor-default duration-150 ease-out motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none [&>svg]:pointer-events-auto"
>
<span className="cursor-default">{option.label}</span>
<span
@@ -224,7 +225,8 @@ export function EnhancedMultiSelect({
{selectedValues.length > maxCount && (
<Badge
variant="tag"
className="m-1 cursor-default [&>svg]:pointer-events-auto"
data-slot="enhanced-multiselect-pill"
className="animate-in fade-in-0 zoom-in-95 m-1 cursor-default duration-150 ease-out motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none [&>svg]:pointer-events-auto"
>
{`+ ${selectedValues.length - maxCount} more`}
<span
@@ -271,7 +273,7 @@ export function EnhancedMultiSelect({
className="flex h-full min-h-6"
/>
<ChevronDown
className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer"
className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none"
aria-hidden="true"
/>
</div>
@@ -281,7 +283,7 @@ export function EnhancedMultiSelect({
<span className="text-text-neutral-tertiary mx-3 text-sm">
{placeholder}
</span>
<ChevronDown className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer" />
<ChevronDown className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none" />
</div>
)}
</Button>
@@ -339,7 +341,7 @@ export function EnhancedMultiSelect({
aria-selected={isSelected}
aria-disabled={option.disabled}
className={cn(
"cursor-pointer",
"cursor-pointer transition-colors duration-150 ease-out motion-reduce:transition-none",
option.disabled && "cursor-not-allowed opacity-50",
)}
disabled={option.disabled}
@@ -83,6 +83,138 @@ describe("MultiSelect", () => {
).not.toBeInTheDocument();
});
it("uses visible trigger and chevron open-state motion", () => {
render(
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
const trigger = screen.getByRole("combobox");
const icon = trigger.querySelector("svg");
expect(trigger).toHaveClass(
"transition-[background-color,border-color,color,box-shadow]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(icon).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"group-aria-expanded:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("uses visible content open and close motion", async () => {
const user = userEvent.setup();
render(
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
const content = document.querySelector("[data-slot='multiselect-content']");
expect(content).toHaveClass(
"duration-200",
"ease-out",
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
it("animates item selection feedback and check visibility", async () => {
const user = userEvent.setup();
render(
<MultiSelect defaultValues={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
const option = screen.getByRole("option", { name: /production aws/i });
const checkIcon = option.querySelector("svg");
expect(option).toHaveClass(
"transition-colors",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(checkIcon).toHaveClass(
"transition-[opacity,transform]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
});
it("animates selected pills when values are added to the trigger", async () => {
const user = userEvent.setup();
render(
<MultiSelect defaultValues={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: /production aws/i }));
const pill = within(screen.getByRole("combobox"))
.getByText("Production AWS")
.closest("[data-selected-item]");
expect(pill).toHaveClass(
"animate-in",
"fade-in-0",
"zoom-in-95",
"duration-150",
"ease-out",
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
it("filters items without crashing when search is enabled", async () => {
const user = userEvent.setup();
+6 -7
View File
@@ -153,15 +153,14 @@ export function MultiSelectTrigger({
data-slot="multiselect-trigger"
data-size={size}
className={cn(
"border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=multiselect-value]:line-clamp-1 *:data-[slot=multiselect-value]:flex *:data-[slot=multiselect-value]:items-center *:data-[slot=multiselect-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=multiselect-value]:line-clamp-1 *:data-[slot=multiselect-value]:flex *:data-[slot=multiselect-value]:items-center *:data-[slot=multiselect-value]:gap-2 motion-reduce:transition-none dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
className,
)}
>
{children}
<ChevronDown
className={cn(
"text-bg-button-secondary size-6 shrink-0 opacity-70 transition-transform duration-200",
open && "rotate-180",
"text-bg-button-secondary size-6 shrink-0 opacity-70 transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none",
)}
/>
</Button>
@@ -267,7 +266,7 @@ export function MultiSelectValue({
<Badge
variant="tag"
data-selected-item
className="group flex items-center gap-1.5 px-2 py-1 text-xs font-medium"
className="group animate-in fade-in-0 zoom-in-95 flex items-center gap-1.5 px-2 py-1 text-xs font-medium duration-150 ease-out motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none"
key={value}
onClick={
clickToRemove
@@ -415,7 +414,7 @@ export function MultiSelectItem({
keywords={keywords}
data-slot="multiselect-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary my-1 flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary my-1 flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden transition-colors duration-150 ease-out select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 motion-reduce:transition-none dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
isSelected && "bg-slate-100 dark:bg-slate-800/50",
disabled && "cursor-not-allowed opacity-50 hover:bg-transparent",
className,
@@ -431,8 +430,8 @@ export function MultiSelectItem({
</span>
<CheckIcon
className={cn(
"text-bg-button-secondary size-5 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
"text-bg-button-secondary size-5 shrink-0 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none",
isSelected ? "scale-100 opacity-100" : "scale-95 opacity-0",
)}
/>
</CommandItem>
+222
View File
@@ -0,0 +1,222 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./select";
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
vi.useRealTimers();
});
function renderTypeSelect({ open = false }: { open?: boolean } = {}) {
return render(
<Select defaultValue="all" open={open} onValueChange={() => {}}>
<SelectTrigger aria-label="All Types">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
</SelectContent>
</Select>,
);
}
describe("Select", () => {
it("renders an open dropdown with selectable options", () => {
// Given
renderTypeSelect({ open: true });
// When
const listbox = screen.getByRole("listbox");
// Then
expect(
within(listbox).getByRole("option", { name: "All Types" }),
).toBeVisible();
expect(
within(listbox).getByRole("option", { name: "Manual" }),
).toBeVisible();
expect(
within(listbox).getByRole("option", { name: "Scheduled" }),
).toBeVisible();
});
it("uses robust trigger transitions for hover, focus, and chevron state", () => {
// Given
renderTypeSelect();
// When
const trigger = screen.getByRole("combobox", { name: "All Types" });
const icon = trigger.querySelector("svg");
// Then
expect(trigger).toHaveClass(
"transition-[background-color,border-color,color,box-shadow]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(icon).toHaveClass(
"transition-[rotate]",
"duration-200",
"ease-out",
"group-data-[state=open]:rotate-180",
"motion-reduce:rotate-0",
"motion-reduce:transition-none",
);
});
it("preserves the Radix open data-state model", () => {
// Given
renderTypeSelect({ open: true });
// When
const listbox = screen.getByRole("listbox");
const content = listbox.closest("[data-slot='select-content']");
// Then
expect(content).toHaveAttribute("data-state", "open");
});
it("keeps content mounted briefly with a closing state", async () => {
// Given
const { rerender } = renderTypeSelect({ open: true });
expect(screen.getByRole("listbox")).toBeVisible();
// When
rerender(
<Select defaultValue="all" open={false} onValueChange={() => {}}>
<SelectTrigger aria-label="All Types">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
</SelectContent>
</Select>,
);
// Then
const content = await waitFor(() =>
screen.getByRole("listbox").closest("[data-slot='select-content']"),
);
const trigger = document.querySelector("[data-slot='select-trigger']");
expect(content).toHaveAttribute("data-closing", "true");
expect(trigger).toHaveAttribute("data-closing", "true");
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
it("keeps uncontrolled content mounted briefly after selecting an option", async () => {
// Given
const user = userEvent.setup();
render(
<Select defaultOpen defaultValue="all" onValueChange={() => {}}>
<SelectTrigger aria-label="All Types">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="scheduled">Scheduled</SelectItem>
</SelectContent>
</Select>,
);
expect(screen.getByRole("listbox")).toBeVisible();
// When
await user.click(screen.getByRole("option", { name: "Manual" }));
// Then
const content = await waitFor(() =>
screen.getByRole("listbox").closest("[data-slot='select-content']"),
);
const trigger = document.querySelector("[data-slot='select-trigger']");
expect(content).toHaveAttribute("data-closing", "true");
expect(content).toHaveClass(
"animate-out",
"fade-out-0",
"zoom-out-95",
"pointer-events-none",
"duration-100",
"ease-in",
);
expect(content).not.toHaveClass(
"data-[state=open]:animate-in",
"data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
);
expect(trigger).toHaveAttribute("data-closing", "true");
});
it("uses explicit open and close motion classes", () => {
// Given
renderTypeSelect({ open: true });
// When
const content = screen
.getByRole("listbox")
.closest("[data-slot='select-content']");
// Then
expect(content).toHaveClass(
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0",
"data-[state=closed]:fade-out-0",
"data-[state=open]:zoom-in-95",
"data-[state=closed]:zoom-out-95",
"duration-200",
"ease-out",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy dropdown motion for reduced motion", () => {
// Given
renderTypeSelect({ open: true });
// When
const content = screen
.getByRole("listbox")
.closest("[data-slot='select-content']");
// Then
expect(content).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
+92 -9
View File
@@ -2,20 +2,90 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { ComponentProps, type WheelEvent } from "react";
import {
ComponentProps,
createContext,
useContext,
useEffect,
useRef,
useState,
type WheelEvent,
} from "react";
import { cn } from "@/lib/utils";
const SELECT_CLOSE_ANIMATION_MS = 100;
interface SelectMotionContextValue {
isClosing: boolean;
}
const SelectMotionContext = createContext<SelectMotionContextValue>({
isClosing: false,
});
const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
event.stopPropagation();
};
function Select({
allowDeselect = false,
open,
defaultOpen,
onOpenChange,
...props
}: ComponentProps<typeof SelectPrimitive.Root> & {
allowDeselect?: boolean;
}) {
const isControlled = open !== undefined;
const [uncontrolledOpen, setUncontrolledOpen] = useState(
defaultOpen ?? false,
);
const requestedOpen = isControlled ? open : uncontrolledOpen;
const [renderedOpen, setRenderedOpen] = useState(requestedOpen);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
if (requestedOpen) {
setIsClosing(false);
setRenderedOpen(true);
return;
}
if (!renderedOpen) {
setIsClosing(false);
return;
}
setIsClosing(true);
closeTimerRef.current = setTimeout(() => {
setRenderedOpen(false);
setIsClosing(false);
closeTimerRef.current = null;
}, SELECT_CLOSE_ANIMATION_MS);
return () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
};
}, [requestedOpen, renderedOpen]);
const handleOpenChange = (nextOpen: boolean) => {
if (!isControlled) {
setUncontrolledOpen(nextOpen);
}
onOpenChange?.(nextOpen);
};
const handleValueChange = (nextValue: string) => {
if (allowDeselect && props.value === nextValue) {
// Single-select with deselect
@@ -27,11 +97,15 @@ function Select({
};
return (
<SelectPrimitive.Root
data-slot="select"
{...props}
onValueChange={handleValueChange}
/>
<SelectMotionContext.Provider value={{ isClosing }}>
<SelectPrimitive.Root
data-slot="select"
{...props}
open={renderedOpen}
onOpenChange={handleOpenChange}
onValueChange={handleValueChange}
/>
</SelectMotionContext.Provider>
);
}
@@ -57,12 +131,15 @@ function SelectTrigger({
size?: "sm" | "default";
iconSize?: "sm" | "default";
}) {
const { isClosing } = useContext(SelectMotionContext);
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
data-closing={isClosing ? "true" : undefined}
className={cn(
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 motion-reduce:transition-none dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
@@ -71,7 +148,8 @@ function SelectTrigger({
<SelectPrimitive.Icon asChild>
<ChevronDownIcon
className={cn(
"text-bg-button-secondary shrink-0 opacity-70 transition-transform duration-200 group-data-[state=open]:rotate-180",
"text-bg-button-secondary shrink-0 opacity-70 transition-[rotate] duration-200 ease-out motion-reduce:rotate-0 motion-reduce:transition-none",
isClosing ? "rotate-0" : "group-data-[state=open]:rotate-180",
iconSize === "sm" ? "size-4" : "size-6",
)}
aria-hidden="true"
@@ -92,6 +170,7 @@ function SelectContent({
}: ComponentProps<typeof SelectPrimitive.Content> & {
width?: "default" | "wide";
}) {
const { isClosing } = useContext(SelectMotionContext);
const widthClasses =
width === "wide"
? "w-[min(max(var(--radix-select-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]"
@@ -101,8 +180,12 @@ function SelectContent({
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-closing={isClosing ? "true" : undefined}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-lg border",
"bg-popover text-popover-foreground data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-lg border duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
isClosing
? "animate-out fade-out-0 zoom-out-95 pointer-events-none duration-100 ease-in"
: "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
widthClasses,
@@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Skeleton } from "./skeleton";
import { SkeletonBoundary } from "./skeleton-boundary";
describe("SkeletonBoundary", () => {
it("wraps resolved content with the shared skeleton reveal", () => {
// Given
render(
<SkeletonBoundary fallback={<Skeleton aria-label="Loading content" />}>
<section aria-label="Resolved content">Ready</section>
</SkeletonBoundary>,
);
// When
const reveal = screen.getByTestId("skeleton-content-reveal");
// Then
expect(screen.getByLabelText("Resolved content")).toBeInTheDocument();
expect(reveal).toHaveAttribute("data-motion", "skeleton-content-handoff");
});
it("forwards className to the reveal wrapper", () => {
// Given
render(
<SkeletonBoundary
fallback={<Skeleton aria-label="Loading content" />}
className="custom-boundary"
>
Ready
</SkeletonBoundary>,
);
// When
const reveal = screen.getByTestId("skeleton-content-reveal");
// Then
expect(reveal).toHaveClass("custom-boundary");
});
});
@@ -0,0 +1,25 @@
import { ReactNode, Suspense } from "react";
import { SkeletonContentReveal } from "./skeleton-content-reveal";
interface SkeletonBoundaryProps {
children: ReactNode;
fallback: ReactNode;
className?: string;
}
function SkeletonBoundary({
children,
fallback,
className,
}: SkeletonBoundaryProps) {
return (
<Suspense fallback={fallback}>
<SkeletonContentReveal className={className}>
{children}
</SkeletonContentReveal>
</Suspense>
);
}
export { SkeletonBoundary };
@@ -0,0 +1,51 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SkeletonContentReveal } from "./skeleton-content-reveal";
describe("SkeletonContentReveal", () => {
it("reveals streamed content with insertion-time CSS motion", () => {
// Given
render(
<SkeletonContentReveal>
<section aria-label="Loaded content">Ready</section>
</SkeletonContentReveal>,
);
// When
const wrapper = screen.getByTestId("skeleton-content-reveal");
// Then
expect(screen.getByLabelText("Loaded content")).toBeInTheDocument();
expect(wrapper).toHaveAttribute("data-motion", "skeleton-content-handoff");
expect(wrapper).toHaveClass(
"transition-[opacity,transform]",
"duration-700",
"starting:opacity-0",
"starting:translate-y-3",
"opacity-100",
"translate-y-0",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
it("merges caller classes without dropping the motion contract", () => {
// Given
render(
<SkeletonContentReveal className="custom-reveal">
Ready
</SkeletonContentReveal>,
);
// When
const wrapper = screen.getByTestId("skeleton-content-reveal");
// Then
expect(wrapper).toHaveClass(
"custom-reveal",
"transition-[opacity,transform]",
"starting:opacity-0",
);
});
});
@@ -0,0 +1,28 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface SkeletonContentRevealProps {
children: ReactNode;
className?: string;
}
function SkeletonContentReveal({
children,
className,
}: SkeletonContentRevealProps) {
return (
<div
data-testid="skeleton-content-reveal"
data-motion="skeleton-content-handoff"
className={cn(
"translate-y-0 opacity-100 transition-[opacity,transform] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transform-none motion-reduce:transition-none starting:translate-y-3 starting:opacity-0",
className,
)}
>
{children}
</div>
);
}
export { SkeletonContentReveal };
@@ -0,0 +1,34 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Skeleton } from "./skeleton";
describe("Skeleton", () => {
it("uses a subtle scanner animation that respects reduced motion", () => {
// Given
render(<Skeleton aria-label="Loading providers" />);
// When
const skeleton = screen.getByLabelText("Loading providers");
// Then
expect(skeleton).toHaveClass(
"relative",
"overflow-hidden",
"bg-border-neutral-tertiary",
"transition-colors",
"duration-500",
"ease-out",
);
const scanner = skeleton.querySelector("[data-slot='skeleton-scanner']");
expect(scanner).toHaveClass(
"animate-skeleton-scan",
"bg-gradient-to-r",
"from-transparent",
"via-white/10",
"to-transparent",
"motion-reduce:hidden",
);
});
});
+13 -3
View File
@@ -1,15 +1,25 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
function Skeleton({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn(
"bg-border-neutral-tertiary animate-pulse rounded-md",
"bg-border-neutral-tertiary relative overflow-hidden rounded-md transition-colors duration-500 ease-out motion-reduce:transition-none",
className,
)}
{...props}
/>
>
<span
data-slot="skeleton-scanner"
className="animate-skeleton-scan pointer-events-none absolute inset-y-0 -left-1/2 w-1/2 bg-gradient-to-r from-transparent via-white/10 to-transparent motion-reduce:hidden"
/>
{children}
</div>
);
}
@@ -33,4 +33,22 @@ describe("LoadingState", () => {
const { container } = render(<LoadingState className="custom-wrapper" />);
expect(container.firstChild).toHaveClass("custom-wrapper");
});
it("animates the loading state entry and label color subtly", () => {
const { container } = render(<LoadingState label="Loading findings..." />);
expect(container.firstChild).toHaveClass(
"animate-in",
"fade-in-0",
"duration-200",
"ease-out",
"motion-reduce:animate-none",
"motion-reduce:transition-none",
);
expect(screen.getByText("Loading findings...")).toHaveClass(
"transition-colors",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
});
});
@@ -17,11 +17,16 @@ export function LoadingState({
}: LoadingStateProps) {
return (
<div
className={cn("flex items-center justify-center gap-2 py-8", className)}
className={cn(
"animate-in fade-in-0 flex items-center justify-center gap-2 py-8 duration-200 ease-out motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
>
<Spinner className={cn("size-6", spinnerClassName)} />
{label && (
<span className="text-text-neutral-tertiary text-sm">{label}</span>
<span className="text-text-neutral-tertiary text-sm transition-colors duration-200 ease-out motion-reduce:transition-none">
{label}
</span>
)}
</div>
);
+4 -1
View File
@@ -19,7 +19,10 @@ interface SpinnerProps {
export function Spinner({ className }: SpinnerProps) {
return (
<svg
className={cn("size-5 shrink-0 animate-spin", className)}
className={cn(
"size-5 shrink-0 animate-spin motion-reduce:animate-none",
className,
)}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
+35
View File
@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
describe("Tabs", () => {
it("animates tab content when switching between tabs", async () => {
// Given - A tabs group with two content panels
const user = userEvent.setup();
render(
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
<TabsContent value="overview">Overview content</TabsContent>
<TabsContent value="events">Events content</TabsContent>
</Tabs>,
);
// When - The user switches to another tab
await user.click(screen.getByRole("tab", { name: /events/i }));
const eventsPanel = screen.getByText("Events content");
// Then - The newly active content keeps the motion-ready content element
expect(eventsPanel).toHaveAttribute("data-slot", "tabs-content");
expect(eventsPanel).toHaveClass(
"will-change-transform",
"motion-reduce:transform-none",
);
expect(eventsPanel).toHaveAttribute("data-state", "active");
});
});
+20 -6
View File
@@ -1,6 +1,7 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { motion, useReducedMotion } from "framer-motion";
import type { ComponentProps, ReactNode } from "react";
import {
@@ -30,7 +31,7 @@ const TRIGGER_STYLES = {
* Content component styles
*/
const CONTENT_STYLES =
"mt-2 focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const;
"mt-2 will-change-transform motion-reduce:transform-none focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const;
/**
* Build trigger className by combining style parts
@@ -122,14 +123,27 @@ function TabsTrigger({
function TabsContent({
className,
children,
...props
}: ComponentProps<typeof TabsPrimitive.Content>) {
const shouldReduceMotion = useReducedMotion();
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(CONTENT_STYLES, className)}
{...props}
/>
<TabsPrimitive.Content asChild {...props}>
<motion.div
data-slot="tabs-content"
initial={shouldReduceMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={
shouldReduceMotion
? { duration: 0 }
: { duration: 0.2, ease: "easeOut" }
}
className={cn(CONTENT_STYLES, className)}
>
{children}
</motion.div>
</TabsPrimitive.Content>
);
}
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Textarea } from "./textarea";
describe("Textarea", () => {
it("uses visible hover and focus microinteraction timing", () => {
// Given - A standard textarea
render(<Textarea aria-label="Reason" />);
// When - The textarea renders
const textarea = screen.getByRole("textbox", { name: /reason/i });
// Then - The focus/hover state changes are intentionally timed
expect(textarea).toHaveClass(
"transition-[background-color,border-color,box-shadow,color]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ import { ComponentProps, forwardRef } from "react";
import { cn } from "@/lib/utils";
const textareaVariants = cva(
"flex w-full rounded-lg border text-sm transition-all outline-none resize-none disabled:cursor-not-allowed disabled:opacity-50",
"flex w-full rounded-lg border text-sm transition-[background-color,border-color,box-shadow,color] duration-150 ease-out outline-none motion-reduce:transition-none resize-none disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
+66
View File
@@ -0,0 +1,66 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
function renderOpenTooltip() {
return render(
<Tooltip open>
<TooltipTrigger>Copy ARN</TooltipTrigger>
<TooltipContent>Copy resource identifier</TooltipContent>
</Tooltip>,
);
}
describe("Tooltip", () => {
it("renders controlled content through the Radix Tooltip API", () => {
// Given
renderOpenTooltip();
// When
const tooltip = document.querySelector("[data-slot='tooltip-content']");
// Then
expect(screen.getByRole("tooltip")).toBeVisible();
expect(tooltip).toBeVisible();
expect(tooltip).toHaveTextContent("Copy resource identifier");
});
it("uses an intentional open and close motion contract", () => {
// Given
renderOpenTooltip();
// When
const tooltip = document.querySelector("[data-slot='tooltip-content']");
// Then
expect(tooltip).toHaveClass(
"origin-(--radix-tooltip-content-transform-origin)",
"animate-in",
"fade-in-0",
"zoom-in-95",
"duration-150",
"ease-out",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=closed]:zoom-out-95",
"data-[state=closed]:duration-100",
"data-[state=closed]:ease-in",
);
});
it("removes transform-heavy tooltip motion for reduced-motion users", () => {
// Given
renderOpenTooltip();
// When
const tooltip = document.querySelector("[data-slot='tooltip-content']");
// Then
expect(tooltip).toHaveClass(
"motion-reduce:animate-none",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
});
});
@@ -58,7 +58,9 @@ export function TreeLeaf({
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5",
"hover:bg-prowler-white/5 cursor-pointer",
"transition-[background-color,box-shadow,color] duration-150 ease-out motion-reduce:transition-none",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
isSelected && "bg-prowler-white/5",
item.disabled && "cursor-not-allowed opacity-50",
item.className,
)}
+4 -2
View File
@@ -96,7 +96,9 @@ export function TreeNode({
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5",
"hover:bg-prowler-white/5 cursor-pointer",
"transition-[background-color,box-shadow,color] duration-150 ease-out motion-reduce:transition-none",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
isSelected && "bg-prowler-white/5",
item.disabled && "cursor-not-allowed opacity-50",
item.className,
)}
@@ -110,7 +112,7 @@ export function TreeNode({
onKeyDown={handleKeyDown}
>
<button
className="hover:bg-prowler-white/10 shrink-0 rounded p-0.5"
className="hover:bg-prowler-white/10 shrink-0 rounded p-0.5 transition-colors duration-150 ease-out motion-reduce:transition-none"
aria-label={isExpanded ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
@@ -123,7 +125,7 @@ export function TreeNode({
) : (
<ChevronRightIcon
className={cn(
"h-4 w-4 transition-transform duration-200",
"h-4 w-4 transition-transform duration-200 ease-out motion-reduce:transition-none",
isExpanded && "rotate-90",
)}
/>
@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TreeView } from "./tree-view";
const treeData = [
{
id: "org-1",
name: "Organization",
children: [
{ id: "account-1", name: "Production" },
{ id: "account-2", name: "Development" },
],
},
];
describe("TreeView", () => {
it("animates node affordances and expanded content", () => {
// Given
render(<TreeView data={treeData} expandedIds={["org-1"]} showCheckboxes />);
// When
const node = screen.getByRole("treeitem", { name: /organization/i });
const expandButton = screen.getByRole("button", { name: /collapse/i });
const chevron = expandButton.querySelector("svg");
const group = screen.getByRole("group");
// Then
expect(node).toHaveClass(
"transition-[background-color,box-shadow,color]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(expandButton).toHaveClass(
"transition-colors",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
expect(chevron).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
"rotate-90",
);
expect(group).toHaveClass("overflow-hidden");
});
it("animates selected leaf row feedback", () => {
// Given
render(
<TreeView
data={treeData}
expandedIds={["org-1"]}
selectedIds={["account-1"]}
onSelectionChange={vi.fn()}
showCheckboxes
/>,
);
// When
const selectedLeaf = screen.getByRole("treeitem", { name: /production/i });
// Then
expect(selectedLeaf).toHaveAttribute("aria-selected", "true");
expect(selectedLeaf).toHaveClass(
"bg-prowler-white/5",
"transition-[background-color,box-shadow,color]",
"duration-150",
"ease-out",
"motion-reduce:transition-none",
);
});
});
@@ -0,0 +1,45 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DataTableSearch } from "./data-table-search";
vi.mock("next/navigation", () => ({
usePathname: () => "/findings",
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({ updateFilter: vi.fn() }),
}));
describe("DataTableSearch", () => {
it("uses visible focus and icon microinteraction timing", async () => {
// Given - A table search field
const user = userEvent.setup();
render(<DataTableSearch placeholder="Search findings" />);
// When - The user focuses the table search
const input = screen.getByRole("searchbox", { name: /search findings/i });
await user.click(input);
const control = screen.getByTestId("data-table-search-control");
const icon = screen.getByTestId("data-table-search-icon");
// Then - The table search control has visible focus/highlight timing
expect(control).toHaveClass(
"transition-[background-color,border-color,box-shadow,color]",
"duration-250",
"ease-out",
"motion-reduce:transition-none",
"focus-within:ring-1",
);
expect(icon).toHaveClass(
"transition-colors",
"duration-250",
"ease-out",
"motion-reduce:transition-none",
);
});
});
+8 -3
View File
@@ -142,13 +142,17 @@ export const DataTableSearch = ({
>
<div className="relative w-full">
<div
data-testid="data-table-search-control"
className={cn(
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary flex items-center gap-1.5 rounded-md border transition-colors",
isFocused && "border-border-input-primary-pressed",
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary focus-within:ring-border-input-primary-press flex items-center gap-1.5 rounded-md border transition-[background-color,border-color,box-shadow,color] duration-250 ease-out focus-within:ring-1 focus-within:ring-inset motion-reduce:transition-none",
isFocused && "border-border-input-primary-press",
)}
>
<div className="flex shrink-0 items-center pl-3">
<SearchIcon className="text-text-neutral-tertiary size-4" />
<SearchIcon
data-testid="data-table-search-icon"
className="text-text-neutral-tertiary size-4 transition-colors duration-250 ease-out motion-reduce:transition-none"
/>
</div>
{hasBadge && (
@@ -180,6 +184,7 @@ export const DataTableSearch = ({
ref={inputRef}
id={id}
type="search"
aria-label={placeholder}
placeholder={placeholder}
value={value}
onChange={(e) => handleChange(e.target.value)}
+3 -1
View File
@@ -329,7 +329,9 @@ export function DataTable<TData, TValue>({
</TableCell>
))}
</TableRow>
{renderAfterRow?.(row)}
<AnimatePresence initial={false}>
{renderAfterRow?.(row)}
</AnimatePresence>
</Fragment>
),
)
+18 -1
View File
@@ -22,9 +22,26 @@ describe("StatusBadge", () => {
);
it("renders the executing state with spinner and progress percentage", () => {
render(<StatusBadge status="executing" loadingProgress={42} />);
const { container } = render(
<StatusBadge status="executing" loadingProgress={42} />,
);
expect(screen.getByText("executing")).toBeInTheDocument();
expect(screen.getByText("42%")).toBeInTheDocument();
expect(container.querySelector("svg")).toHaveClass(
"animate-spin",
"motion-reduce:animate-none",
);
});
it("animates status color changes without layout motion", () => {
const { container } = render(<StatusBadge status="completed" />);
const badge = container.querySelector("[data-slot='badge']");
expect(badge).toHaveClass(
"transition-[background-color,border-color,color,box-shadow]",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
});
it("omits progress when loadingProgress is not provided", () => {
+4 -1
View File
@@ -68,7 +68,10 @@ export const StatusBadge = ({
>
{status === "executing" ? (
<span className="inline-flex items-center gap-1">
<SpinnerIcon size={12} className="animate-spin" />
<SpinnerIcon
size={12}
className="animate-spin motion-reduce:animate-none"
/>
{loadingProgress !== undefined && (
<span className="text-[0.6rem]">{loadingProgress}%</span>
)}
+15
View File
@@ -409,6 +409,21 @@
transform-box: fill-box;
transform-origin: center;
}
@keyframes skeletonScan {
0% {
transform: translateX(0);
}
55%,
100% {
transform: translateX(300%);
}
}
.animate-skeleton-scan {
animation: skeletonScan 2.6s ease-in-out infinite;
}
}
/* ===== BASE LAYER ===== */