Compare commits

..
Author SHA1 Message Date
Alan Buscaglia b736ac320f 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-11 16:48:04 +02:00
Alan Buscaglia c41e7e735b fix(ui): animate collapsible height and degrade tree motion under reduced motion 2026-06-11 16:47:55 +02:00
Alan Buscaglia 137fb6388f 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-11 16:47:55 +02:00
Alan Buscaglia 4648bb29d3 fix(ui): degrade search-input clear button motion under reduced motion 2026-06-11 16:47:46 +02:00
Alan Buscaglia 44e36421b1 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-11 16:47:46 +02:00
46 changed files with 955 additions and 133 deletions
+2
View File
@@ -35,6 +35,7 @@ Use these skills for detailed patterns on-demand:
| `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-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) |
@@ -85,6 +86,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Creating new skills | `skill-creator` |
| Creating or reviewing Django migrations | `django-migration-psql` |
| Creating/modifying Prowler UI components | `prowler-ui` |
| 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` |
+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`
+31 -29
View File
@@ -3,6 +3,7 @@
> **Skills Reference**: For detailed patterns, use these skills:
>
> - [`prowler-ui`](../skills/prowler-ui/SKILL.md) - Prowler-specific UI patterns
> - [`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 +20,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` |
---
+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>
);
+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>
);
+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: {
+50
View File
@@ -0,0 +1,50 @@
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",
"data-[state=open]:animate-collapsible-down",
"data-[state=closed]:animate-collapsible-up",
);
});
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:transition-none",
);
});
});
+10
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,19 @@ function CollapsibleTrigger({
}
function CollapsibleContent({
className,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
className={cn(
"overflow-hidden",
"data-[state=open]:animate-collapsible-down",
"data-[state=closed]:animate-collapsible-up",
"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: {
+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,68 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { getClearButtonMotion, SearchInput } from "./search-input";
describe("getClearButtonMotion", () => {
it("animates with scale when motion is allowed", () => {
// Given / When
const motion = getClearButtonMotion(false);
// Then
expect(motion.animate).toHaveProperty("scale", 1);
expect(motion.initial).toHaveProperty("scale");
expect(motion.exit).toHaveProperty("scale");
expect(motion.transition.duration).toBeGreaterThan(0);
});
it("degrades to opacity-only with no scale under reduced motion", () => {
// Given / When
const motion = getClearButtonMotion(true);
// Then
expect(motion.initial).not.toHaveProperty("scale");
expect(motion.animate).not.toHaveProperty("scale");
expect(motion.exit).not.toHaveProperty("scale");
expect(motion.transition.duration).toBe(0);
});
});
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, useReducedMotion } 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: {
@@ -66,6 +67,24 @@ export interface SearchInputProps
onClear?: () => void;
}
export function getClearButtonMotion(shouldReduceMotion: boolean) {
if (shouldReduceMotion) {
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0, ease: "easeOut" as const },
};
}
return {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.95 },
transition: { duration: 0.25, ease: "easeOut" as const },
};
}
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
(
{
@@ -83,13 +102,15 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
const iconPosition = iconPositionMap[size || "default"];
const clearButtonPosition = clearButtonPositionMap[size || "default"];
const hasValue = value && String(value).length > 0;
const shouldReduceMotion = useReducedMotion();
const clearButtonMotion = getClearButtonMotion(!!shouldReduceMotion);
return (
<div className={cn(searchInputWrapperVariants({ size }))}>
<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 +123,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={clearButtonMotion.initial}
animate={clearButtonMotion.animate}
exit={clearButtonMotion.exit}
transition={clearButtonMotion.transition}
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,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"
@@ -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: {
@@ -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,
)}
+29 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ChevronRightIcon } from "lucide-react";
import { KeyboardEvent } from "react";
@@ -14,6 +14,24 @@ import { TreeSpinner } from "./tree-spinner";
import { TreeStatusIndicator } from "./tree-status-indicator";
import { getAllDescendantIds, getTreeNodePadding } from "./utils";
export function getTreeChildrenMotion(shouldReduceMotion: boolean) {
if (shouldReduceMotion) {
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0, ease: "easeInOut" as const },
};
}
return {
initial: { opacity: 0, height: 0 },
animate: { opacity: 1, height: "auto" as const },
exit: { opacity: 0, height: 0 },
transition: { duration: 0.2, ease: "easeInOut" as const },
};
}
/**
* TreeNode component for rendering expandable nodes with children.
*
@@ -36,6 +54,8 @@ export function TreeNode({
renderItem,
enableSelectChildren,
}: TreeNodeProps) {
const shouldReduceMotion = useReducedMotion();
const childrenMotion = getTreeChildrenMotion(!!shouldReduceMotion);
const isExpanded = expandedIds.includes(item.id);
const isSelected = selectedIds.includes(item.id);
const statusIcon =
@@ -96,7 +116,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 +132,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 +145,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",
)}
/>
@@ -164,10 +186,10 @@ export function TreeNode({
{isExpanded && (
<motion.ul
key={`children-${item.id}`}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
initial={childrenMotion.initial}
animate={childrenMotion.animate}
exit={childrenMotion.exit}
transition={childrenMotion.transition}
className="mt-1 space-y-1 overflow-hidden"
role="group"
>
@@ -0,0 +1,100 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { getTreeChildrenMotion } from "./tree-node";
import { TreeView } from "./tree-view";
describe("getTreeChildrenMotion", () => {
it("animates height when motion is allowed", () => {
// Given / When
const motion = getTreeChildrenMotion(false);
// Then
expect(motion.initial).toHaveProperty("height", 0);
expect(motion.animate).toHaveProperty("height", "auto");
expect(motion.transition.duration).toBeGreaterThan(0);
});
it("degrades to opacity-only with no height under reduced motion", () => {
// Given / When
const motion = getTreeChildrenMotion(true);
// Then
expect(motion.initial).not.toHaveProperty("height");
expect(motion.animate).not.toHaveProperty("height");
expect(motion.exit).not.toHaveProperty("height");
expect(motion.transition.duration).toBe(0);
});
});
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)}
+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 ===== */