From 14a049a5735957841e0e73f55507d495e957a48f Mon Sep 17 00:00:00 2001 From: Eleven Date: Sun, 25 Oct 2026 14:04:32 +0800 Subject: [PATCH 001/162] docs: add glossary and adr for figma-theme single source --- GLOSSARY.md | 21 +++++++++++++++++++++ docs/adr/0001-figma-theme-single-source.md | 14 ++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 GLOSSARY.md create mode 100644 docs/adr/0001-figma-theme-single-source.md diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000..d7a04cc --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,21 @@ +# Thinkbuff Figma + +Libraries for building Figma and FigJam plugin and widget UIs that look native to the host editor. + +## Language + +**Editor theme**: +The visual language of a host editor, either the Figma theme or the FigJam theme, each with a light and a dark mode. +_Avoid_: skin, palette + +**Theme token**: +A named design value (color, font size, radius, spacing) taken from the host editor's own design system. +_Avoid_: design variable, CSS variable (that is how a token is delivered, not what it is) + +**Color scope**: +The role a color token plays: background, text, border, or icon. The same name in two scopes is two different tokens. +_Avoid_: color category, color type + +**Utility contract**: +The set of class names the theme package guarantees to consumers, shared by the React and Vue component libraries. +_Avoid_: preset, class API diff --git a/docs/adr/0001-figma-theme-single-source.md b/docs/adr/0001-figma-theme-single-source.md new file mode 100644 index 0000000..ff79a77 --- /dev/null +++ b/docs/adr/0001-figma-theme-single-source.md @@ -0,0 +1,14 @@ +# This repo is the single publisher of @thinkbuff/figma-theme + +`@thinkbuff/figma-theme` existed in two places with the same package name: this repo (UnoCSS preset, published as 0.0.x) and the `thinkbuff/platform` monorepo (Tailwind CSS v4 rewrite with generated utilities and a token sync pipeline, source-only, never published). We port the platform implementation into `packages/theme` here and make this repo the only place the package is built and published; platform will later consume the published package instead of its own copy. The UnoCSS preset export (`./unocss`) is dropped, not kept alongside, so the theme has one utility contract shared by the React and Vue component libraries. + +## Considered Options + +- Rewrite a Tailwind theme independently in this repo: rejected, would fork the utility contract from platform's Vue `figma-ui`. +- Depend on platform's package: rejected, it ships TypeScript sources with no build and lives in a private-app monorepo. +- Keep both UnoCSS and Tailwind exports: rejected, doubles the surface to maintain and test for no current consumer. + +## Consequences + +- Until platform is switched over, the two copies can drift; token changes must be made here first. +- Removing `./unocss` is a breaking change for existing consumers (released as 0.1.0). From 61adc082e7f2e9c716942f1bb3180943bbdd5556 Mon Sep 17 00:00:00 2001 From: Eleven Date: Sun, 25 Oct 2026 15:00:19 +0800 Subject: [PATCH 002/162] test: add vitest unit and browser-mode behavior tests --- .gitignore | 4 + package.json | 5 +- packages/react/eslint.config.mjs | 1 + packages/react/package.json | 13 +- .../action-icon/action-icon.test.tsx | 31 ++ .../alert-dialog/alert-dialog.test.tsx | 75 +++ .../components/button/button-variants.test.ts | 24 + .../src/components/button/button.test.tsx | 32 ++ .../src/components/checkbox/checkbox.test.tsx | 89 +++ .../src/components/dialog/dialog.test.tsx | 87 +++ .../dropdown-menu/dropdown-menu.test.tsx | 146 +++++ .../components/input/input-variants.test.ts | 23 + .../react/src/components/input/input.test.tsx | 62 +++ .../react/src/components/label/label.test.tsx | 26 + .../src/components/popover/popover.test.tsx | 60 ++ .../react/src/components/radio/radio.test.tsx | 102 ++++ .../scroll-area/scroll-area.test.tsx | 42 ++ .../src/components/select/select.test.tsx | 101 ++++ .../components/separator/separator.test.tsx | 37 ++ .../react/src/components/sheet/sheet.test.tsx | 87 +++ .../src/components/slider/slider.test.tsx | 84 +++ .../src/components/switch/switch.test.tsx | 51 ++ .../react/src/components/tabs/tabs.test.tsx | 71 +++ .../src/components/textarea/textarea.test.tsx | 47 ++ .../react/src/components/toast/toast.test.tsx | 38 ++ .../toggle-group/toggle-group.test.tsx | 71 +++ .../src/components/toggle/toggle.test.tsx | 42 ++ .../src/components/tooltip/tooltip.test.tsx | 59 ++ packages/react/src/test-utils/index.ts | 94 ++++ packages/react/src/test-utils/setup.ts | 4 + packages/react/src/test-utils/vitest.d.ts | 1 + packages/react/src/utils/cn.test.ts | 27 + packages/react/tsconfig.node.json | 2 +- packages/react/vite.config.ts | 4 +- packages/react/vitest.config.ts | 84 +++ packages/theme/package.json | 6 +- packages/theme/src/colors/colors.test.ts | 52 ++ packages/theme/vitest.config.ts | 8 + packages/utils/package.json | 8 +- packages/utils/src/debounce.test.ts | 50 ++ packages/utils/src/image/image-paint.test.ts | 74 +++ packages/utils/src/message-channel.test.ts | 340 ++++++++++++ packages/utils/src/node/clone-deep.test.ts | 47 ++ .../src/node/clone-nodes-to-maximum.test.ts | 53 ++ packages/utils/src/node/supports.test.ts | 95 ++++ packages/utils/tsconfig.node.json | 2 +- packages/utils/vite.config.ts | 5 +- packages/utils/vitest.config.ts | 17 + playground/package.json | 2 +- pnpm-lock.yaml | 522 +++++++++++++++--- 50 files changed, 2916 insertions(+), 91 deletions(-) create mode 100644 packages/react/src/components/action-icon/action-icon.test.tsx create mode 100644 packages/react/src/components/alert-dialog/alert-dialog.test.tsx create mode 100644 packages/react/src/components/button/button-variants.test.ts create mode 100644 packages/react/src/components/button/button.test.tsx create mode 100644 packages/react/src/components/checkbox/checkbox.test.tsx create mode 100644 packages/react/src/components/dialog/dialog.test.tsx create mode 100644 packages/react/src/components/dropdown-menu/dropdown-menu.test.tsx create mode 100644 packages/react/src/components/input/input-variants.test.ts create mode 100644 packages/react/src/components/input/input.test.tsx create mode 100644 packages/react/src/components/label/label.test.tsx create mode 100644 packages/react/src/components/popover/popover.test.tsx create mode 100644 packages/react/src/components/radio/radio.test.tsx create mode 100644 packages/react/src/components/scroll-area/scroll-area.test.tsx create mode 100644 packages/react/src/components/select/select.test.tsx create mode 100644 packages/react/src/components/separator/separator.test.tsx create mode 100644 packages/react/src/components/sheet/sheet.test.tsx create mode 100644 packages/react/src/components/slider/slider.test.tsx create mode 100644 packages/react/src/components/switch/switch.test.tsx create mode 100644 packages/react/src/components/tabs/tabs.test.tsx create mode 100644 packages/react/src/components/textarea/textarea.test.tsx create mode 100644 packages/react/src/components/toast/toast.test.tsx create mode 100644 packages/react/src/components/toggle-group/toggle-group.test.tsx create mode 100644 packages/react/src/components/toggle/toggle.test.tsx create mode 100644 packages/react/src/components/tooltip/tooltip.test.tsx create mode 100644 packages/react/src/test-utils/index.ts create mode 100644 packages/react/src/test-utils/setup.ts create mode 100644 packages/react/src/test-utils/vitest.d.ts create mode 100644 packages/react/src/utils/cn.test.ts create mode 100644 packages/react/vitest.config.ts create mode 100644 packages/theme/src/colors/colors.test.ts create mode 100644 packages/theme/vitest.config.ts create mode 100644 packages/utils/src/debounce.test.ts create mode 100644 packages/utils/src/image/image-paint.test.ts create mode 100644 packages/utils/src/message-channel.test.ts create mode 100644 packages/utils/src/node/clone-deep.test.ts create mode 100644 packages/utils/src/node/clone-nodes-to-maximum.test.ts create mode 100644 packages/utils/src/node/supports.test.ts create mode 100644 packages/utils/vitest.config.ts diff --git a/.gitignore b/.gitignore index c0a6bd3..d5560a7 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,7 @@ coverage # parcel-bundler cache (https://parceljs.org/) .cache + +# storybook static build output +storybook-static +.vitest diff --git a/package.json b/package.json index 7b9d60c..a1d958c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "release": "changelogen --release --no-commit --no-tag && tsx scripts/update-version.ts", "storybook": "pnpm -F @thinkbuff/figma-react storybook", "postinstall": "pnpm run -r stub", - "prepare": "simple-git-hooks" + "prepare": "simple-git-hooks", + "test": "pnpm -r test", + "test:visual": "pnpm -F @thinkbuff/figma-react test:visual", + "test:visual:update": "pnpm -F @thinkbuff/figma-react test:visual:update" }, "packageManager": "pnpm@10.11.0", "engines": { diff --git a/packages/react/eslint.config.mjs b/packages/react/eslint.config.mjs index 73cf5f2..4efb22e 100644 --- a/packages/react/eslint.config.mjs +++ b/packages/react/eslint.config.mjs @@ -4,6 +4,7 @@ import { defineFlatConfigAsync } from '@thinkbuff/eslint-config'; export default defineFlatConfigAsync({ unocss: true, extends: [ + { ignores: ['storybook-static', '**/__screenshots__'] }, ...storybook.configs['flat/recommended'], ], }); diff --git a/packages/react/package.json b/packages/react/package.json index ffa18d2..1f95203 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -13,7 +13,10 @@ "typecheck": "tsc --noEmit", "storybook": "storybook dev -h 127.0.0.1 -p 6006", "build-storybook": "storybook build", - "prepublishOnly": "pnpm build" + "prepublishOnly": "pnpm build", + "test": "vitest run --project browser --project unit", + "test:visual": "vitest run --project vrt", + "test:visual:update": "vitest run --project vrt --update" }, "homepage": "https://github.com/thinkbuff/figma/tree/main/packages/react#readme", "repository": { @@ -95,15 +98,19 @@ "@thinkbuff/figma-theme": "workspace:*", "@unocss/preset-rem-to-px": "^66.1.2", "@vitejs/plugin-react": "^4.4.1", + "@vitest/browser-playwright": "5.0.5", "glob": "^11.0.2", + "playwright": "1.64.1", "react": "^19.1.0", "react-dom": "^19.1.0", "storybook": "^8.6.14", "typescript": "^5.8.3", "unocss": "^66.1.2", "unocss-preset-animations": "^1.2.1", - "vite": "^6.3.5", - "vite-plugin-dts": "^4.5.4" + "vite": "6.4.4", + "vite-plugin-dts": "^4.5.4", + "vitest": "5.0.5", + "vitest-browser-react": "2.3.0" }, "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.13", diff --git a/packages/react/src/components/action-icon/action-icon.test.tsx b/packages/react/src/components/action-icon/action-icon.test.tsx new file mode 100644 index 0000000..5495a4a --- /dev/null +++ b/packages/react/src/components/action-icon/action-icon.test.tsx @@ -0,0 +1,31 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ActionIcon } from './action-icon'; + +describe('ActionIcon', () => { + it('renders a button with an accessible name', async () => { + await render(); + + await expect.element(page.getByRole('button', { name: 'Settings' })).toBeVisible(); + }); + + it('fires onClick when clicked', async () => { + const onClick = vi.fn(); + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Settings' })); + + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('does not fire onClick when disabled', async () => { + const onClick = vi.fn(); + await render(); + + await expect.element(page.getByRole('button', { name: 'Settings' })).toBeDisabled(); + + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/components/alert-dialog/alert-dialog.test.tsx b/packages/react/src/components/alert-dialog/alert-dialog.test.tsx new file mode 100644 index 0000000..0fbf3e5 --- /dev/null +++ b/packages/react/src/components/alert-dialog/alert-dialog.test.tsx @@ -0,0 +1,75 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it } from 'vitest'; + +import { clickOutside } from '~/test-utils'; + +import { Button } from '../button'; + +import { AlertDialog } from './index'; + +function Fixture() { + return ( + + + + + + + Are you absolutely sure? + + + This action cannot be undone. + + + + + + + + + + + + ); +} + +describe('AlertDialog', () => { + it('opens an alertdialog with an accessible name', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Delete account' })); + + await expect.element(page.getByRole('alertdialog', { name: 'Are you absolutely sure?' })) + .toBeVisible(); + }); + + it('closes on Cancel', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Delete account' })); + await userEvent.click(page.getByRole('button', { name: 'Cancel' })); + + await expect.element(page.getByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('closes on Action', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Delete account' })); + await userEvent.click(page.getByRole('button', { name: 'Yes, delete account' })); + + await expect.element(page.getByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('does not close on an outside click', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Delete account' })); + await expect.element(page.getByRole('alertdialog')).toBeVisible(); + + await clickOutside(); + + await expect.element(page.getByRole('alertdialog')).toBeVisible(); + }); +}); diff --git a/packages/react/src/components/button/button-variants.test.ts b/packages/react/src/components/button/button-variants.test.ts new file mode 100644 index 0000000..c7108eb --- /dev/null +++ b/packages/react/src/components/button/button-variants.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { buttonVariants } from './button-variants'; + +describe('buttonVariants', () => { + it('applies the default variant', () => { + expect(buttonVariants()).toMatchInlineSnapshot( + `"h-8 px-3 inline-flex items-center justify-center shrink-0 rounded-1.5 font-size-11 truncate font-medium border-none cursor-default transition-colors outline-solid outline-1 -outline-offset-1 outline-transparent bg-transparent bg-clip-border not-disabled:focus-visible:(outline-2 -outline-offset-2) disabled:cursor-not-allowed focus:not-focus-visible:(outline-0 outline-transparent) disabled:(text-figma-ondisabled bg-figma-disabled) disabled:hover:bg-figma-disabled disabled:active:(bg-figma-disabled outline-transparent) disabled:focus-visible:outline-transparent btn-primary text-figma-onbrand bg-figma-brand hover:bg-figma-brand-hover active:bg-figma-brand-pressed focus-visible:outline-figma-border-brand-strong data-[state=open]:bg-figma-brand-pressed"`, + ); + }); + + it('applies variant and color combinations', () => { + expect(buttonVariants({ variant: 'outline', color: 'danger' })).toMatchInlineSnapshot( + `"h-8 px-3 inline-flex items-center justify-center shrink-0 rounded-1.5 font-size-11 truncate font-medium border-none cursor-default transition-colors outline-solid outline-1 -outline-offset-1 outline-transparent bg-transparent bg-clip-border not-disabled:focus-visible:(outline-2 -outline-offset-2) disabled:cursor-not-allowed disabled:(text-figma-disabled bg-transparent outline-figma-text-disabled) disabled:hover:bg-transparent disabled:active:bg-transparent btn-danger text-figma-danger outline-figma-text-danger hover:bg-figma-danger-tertiary active:bg-figma-danger-tertiary focus-visible:outline-figma-border-danger-strong data-[state=open]:bg-figma-danger-tertiary"`, + ); + expect(buttonVariants({ variant: 'link', color: 'secondary' })).toMatchInlineSnapshot( + `"h-8 px-3 inline-flex items-center justify-center shrink-0 rounded-1.5 font-size-11 truncate font-medium border-none cursor-default transition-colors outline-solid outline-1 -outline-offset-1 outline-transparent bg-transparent bg-clip-border not-disabled:focus-visible:(outline-2 -outline-offset-2) disabled:cursor-not-allowed hover:underline disabled:(text-figma-disabled no-underline) btn-secondary text-figma"`, + ); + }); + + it('appends extra className', () => { + expect(buttonVariants({ className: 'extra' })).toContain('extra'); + }); +}); diff --git a/packages/react/src/components/button/button.test.tsx b/packages/react/src/components/button/button.test.tsx new file mode 100644 index 0000000..ce66e56 --- /dev/null +++ b/packages/react/src/components/button/button.test.tsx @@ -0,0 +1,32 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Button } from './button'; + +describe('Button', () => { + it('renders a button with an accessible name', async () => { + await render(); + + await expect.element(page.getByRole('button', { name: 'Click me' })).toBeVisible(); + }); + + it('fires onClick when clicked', async () => { + const onClick = vi.fn(); + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Click me' })); + + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('does not fire onClick when disabled', async () => { + const onClick = vi.fn(); + await render(); + + const button = page.getByRole('button', { name: 'Click me' }); + await expect.element(button).toBeDisabled(); + + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/components/checkbox/checkbox.test.tsx b/packages/react/src/components/checkbox/checkbox.test.tsx new file mode 100644 index 0000000..c9950dc --- /dev/null +++ b/packages/react/src/components/checkbox/checkbox.test.tsx @@ -0,0 +1,89 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { firstArg } from '~/test-utils'; + +import { Label } from '../label'; + +import { Checkbox } from './checkbox'; + +describe('Checkbox', () => { + it('toggles aria-checked on click', async () => { + await render(); + + const checkbox = page.getByRole('checkbox', { name: 'Accept' }); + await expect.element(checkbox).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(checkbox); + await expect.element(checkbox).toHaveAttribute('aria-checked', 'true'); + + await userEvent.click(checkbox); + await expect.element(checkbox).toHaveAttribute('aria-checked', 'false'); + }); + + it('toggles with Space key', async () => { + await render(); + + const checkbox = page.getByRole('checkbox', { name: 'Accept' }); + (checkbox.element() as HTMLElement).focus(); + + await userEvent.keyboard('{ }'); + await expect.element(checkbox).toHaveAttribute('aria-checked', 'true'); + + await userEvent.keyboard('{ }'); + await expect.element(checkbox).toHaveAttribute('aria-checked', 'false'); + }); + + it('respects controlled checked', async () => { + await render(); + + await expect.element(page.getByRole('checkbox', { name: 'Accept' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('reports aria-checked="mixed" when indeterminate', async () => { + await render(); + + await expect.element(page.getByRole('checkbox', { name: 'Accept' })).toHaveAttribute( + 'aria-checked', + 'mixed', + ); + }); + + it('does not toggle when disabled', async () => { + const onCheckedChange = vi.fn(); + await render(); + + const checkbox = page.getByRole('checkbox', { name: 'Accept' }); + await expect.element(checkbox).toBeDisabled(); + + expect(onCheckedChange).not.toHaveBeenCalled(); + }); + + it('calls onCheckedChange with the new state', async () => { + const onCheckedChange = vi.fn(); + await render(); + + await userEvent.click(page.getByRole('checkbox', { name: 'Accept' })); + + expect(onCheckedChange).toHaveBeenCalledOnce(); + expect(firstArg(onCheckedChange)).toBe(true); + }); + + it('toggles when an associated label is clicked', async () => { + await render( + <> + + + , + ); + + const checkbox = page.getByRole('checkbox', { name: 'Accept terms' }); + await userEvent.click(page.getByText('Accept terms')); + + await expect.element(checkbox).toHaveAttribute('aria-checked', 'true'); + }); +}); diff --git a/packages/react/src/components/dialog/dialog.test.tsx b/packages/react/src/components/dialog/dialog.test.tsx new file mode 100644 index 0000000..7ce1808 --- /dev/null +++ b/packages/react/src/components/dialog/dialog.test.tsx @@ -0,0 +1,87 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { firstArg } from '~/test-utils'; + +import { Button } from '../button'; + +import { Dialog } from './index'; + +function Fixture({ open, onOpenChange }: { open?: boolean; onOpenChange?: (open: boolean) => void }) { + return ( + + + + + + + Edit profile + + + + + Make changes to your profile here. + + + ); +} + +describe('Dialog', () => { + it('opens a dialog with an accessible name and description', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Edit profile' })); + + const dialog = page.getByRole('dialog', { name: 'Edit profile' }); + await expect.element(dialog).toBeVisible(); + await expect.element(dialog).toContainElement( + page.getByText('Make changes to your profile here.').element() as HTMLElement, + ); + }); + + it('moves focus inside the dialog on open', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Edit profile' })); + await expect.element(page.getByRole('dialog')).toBeVisible(); + + const dialog = page.getByRole('dialog').element() as HTMLElement; + expect(dialog.contains(document.activeElement)).toBe(true); + }); + + it('closes on Escape', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Edit profile' })); + await expect.element(page.getByRole('dialog')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes via the close button and returns focus to the trigger', async () => { + await render(); + + const trigger = page.getByRole('button', { name: 'Edit profile' }); + await userEvent.click(trigger); + await userEvent.click(page.getByRole('button', { name: 'Close' })); + + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + await expect.element(trigger).toHaveFocus(); + }); + + it('supports controlled open with onOpenChange', async () => { + const onOpenChange = vi.fn(); + const screen = await render(); + + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + + await screen.rerender(); + await expect.element(page.getByRole('dialog', { name: 'Edit profile' })).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + expect(onOpenChange).toHaveBeenCalled(); + expect(firstArg(onOpenChange)).toBe(false); + }); +}); diff --git a/packages/react/src/components/dropdown-menu/dropdown-menu.test.tsx b/packages/react/src/components/dropdown-menu/dropdown-menu.test.tsx new file mode 100644 index 0000000..71f6ada --- /dev/null +++ b/packages/react/src/components/dropdown-menu/dropdown-menu.test.tsx @@ -0,0 +1,146 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { clickForce, firstArg } from '~/test-utils'; + +import { DropdownMenu } from './index'; + +function Fixture({ + onSelect, + onCheckedChange, + onRadioChange, + checked = true, + radioValue = 'a', +}: { + onSelect?: () => void; + onCheckedChange?: (checked: boolean) => void; + onRadioChange?: (value: string) => void; + checked?: boolean; + radioValue?: string; +}) { + return ( + + Open menu + + Undo + Redo + + Show grid + + + Layout A + Layout B + + + More tools + + Save page + + + + + ); +} + +describe('DropdownMenu', () => { + it('opens a menu on trigger click', async () => { + await render(); + + const trigger = page.getByRole('button', { name: 'Open menu' }); + const triggerEl = trigger.element() as HTMLElement; + await expect.element(trigger).toHaveAttribute('aria-expanded', 'false'); + + await userEvent.click(trigger); + + // while the modal menu is open the trigger is hidden from the + // accessibility tree, so assert on the captured element + expect(triggerEl.getAttribute('aria-expanded')).toBe('true'); + await expect.element(page.getByRole('menu')).toBeVisible(); + }); + + it('fires the item handler on click', async () => { + const onSelect = vi.fn(); + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Open menu' })); + await userEvent.click(page.getByRole('menuitem', { name: 'Undo' })); + + expect(onSelect).toHaveBeenCalledOnce(); + }); + + it('closes the menu after an item click (expected behavior)', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Open menu' })); + await userEvent.click(page.getByRole('menuitem', { name: 'Undo' })); + + await expect.element(page.getByRole('menu')).not.toBeInTheDocument(); + }); + + it('toggles a checkbox item', async () => { + const onCheckedChange = vi.fn(); + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Open menu' })); + + const item = page.getByRole('menuitemcheckbox', { name: 'Show grid' }); + await expect.element(item).toHaveAttribute('aria-checked', 'true'); + + await userEvent.click(item); + expect(onCheckedChange).toHaveBeenCalledOnce(); + expect(firstArg(onCheckedChange)).toBe(false); + }); + + it('renders radio items with checked state', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Open menu' })); + + await expect + .element(page.getByRole('menuitemradio', { name: 'Layout B' })) + .toHaveAttribute('aria-checked', 'true'); + await expect + .element(page.getByRole('menuitemradio', { name: 'Layout A' })) + .toHaveAttribute('aria-checked', 'false'); + }); + + it('opens a submenu with ArrowRight', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Open menu' })); + + const subTrigger = page.getByRole('menuitem', { name: 'More tools' }); + (subTrigger.element() as HTMLElement).focus(); + await userEvent.keyboard('{ArrowRight}'); + + await expect.element(page.getByRole('menuitem', { name: 'Save page' })).toBeVisible(); + }); + + it('closes on Escape and returns focus to the trigger', async () => { + await render(); + + const trigger = page.getByRole('button', { name: 'Open menu' }); + await userEvent.click(trigger); + await expect.element(page.getByRole('menu')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + + await expect.element(page.getByRole('menu')).not.toBeInTheDocument(); + await expect.element(trigger).toHaveFocus(); + }); + + it('does not fire the handler for a disabled item', async () => { + const onSelect = vi.fn(); + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Open menu' })); + + const disabled = page.getByRole('menuitem', { name: 'Redo' }); + await expect.element(disabled).toHaveAttribute('aria-disabled', 'true'); + + await clickForce(disabled); + + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/components/input/input-variants.test.ts b/packages/react/src/components/input/input-variants.test.ts new file mode 100644 index 0000000..4679e39 --- /dev/null +++ b/packages/react/src/components/input/input-variants.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { inputVariants } from './input-variants'; + +describe('inputVariants', () => { + it('applies the default variant', () => { + expect(inputVariants()).toMatchInlineSnapshot( + `"w-full font-size-11 text-figma fill-figma-icon cursor-default bg-figma bg-clip-border border outline-solid outline-1 -outline-offset-2 outline-transparent rounded-0.5 placeholder:text-figma-tertiary focus:(border-figma-selected outline-figma-border-selected) border-transparent not-focus:hover:border-figma"`, + ); + }); + + it('applies the border variant', () => { + expect(inputVariants({ variant: 'border' })).toMatchInlineSnapshot( + `"w-full font-size-11 text-figma fill-figma-icon cursor-default bg-figma bg-clip-border border outline-solid outline-1 -outline-offset-2 outline-transparent rounded-0.5 placeholder:text-figma-tertiary focus:(border-figma-selected outline-figma-border-selected) border-figma"`, + ); + }); + + it('applies invalid and disabled states', () => { + expect(inputVariants({ invalid: true })).toContain('border-figma-danger'); + expect(inputVariants({ disabled: true })).toContain('cursor-not-allowed'); + expect(inputVariants({ disabled: true })).toContain('text-figma-disabled'); + }); +}); diff --git a/packages/react/src/components/input/input.test.tsx b/packages/react/src/components/input/input.test.tsx new file mode 100644 index 0000000..2e016d2 --- /dev/null +++ b/packages/react/src/components/input/input.test.tsx @@ -0,0 +1,62 @@ +import { createRef } from 'react'; +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Input, InputSlot } from './input'; + +describe('Input', () => { + it('renders a textbox', async () => { + await render(); + + await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeVisible(); + }); + + it('updates value and calls onChange when typing', async () => { + const onChange = vi.fn(); + await render(); + + const input = page.getByRole('textbox', { name: 'Name' }); + await userEvent.fill(input, 'hello'); + + await expect.element(input).toHaveValue('hello'); + expect(onChange).toHaveBeenCalled(); + }); + + it('is disabled', async () => { + await render(); + + await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeDisabled(); + }); + + it('is marked invalid', async () => { + await render(); + + await expect.element(page.getByRole('textbox', { name: 'Name' })).toHaveAttribute( + 'aria-invalid', + 'true', + ); + }); + + it('forwards ref to the input element', async () => { + const ref = createRef(); + await render(); + + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); + + it('focuses the input when the wrapper is clicked', async () => { + const ref = createRef(); + await render( + + + icon + + , + ); + + await userEvent.click(page.getByText('icon')); + + await expect.element(page.getByRole('textbox', { name: 'Name' })).toHaveFocus(); + }); +}); diff --git a/packages/react/src/components/label/label.test.tsx b/packages/react/src/components/label/label.test.tsx new file mode 100644 index 0000000..e706432 --- /dev/null +++ b/packages/react/src/components/label/label.test.tsx @@ -0,0 +1,26 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it } from 'vitest'; + +import { Label } from './label'; + +describe('Label', () => { + it('renders label text', async () => { + await render(); + + await expect.element(page.getByText('Name')).toBeVisible(); + }); + + it('focuses the associated control on click', async () => { + await render( + <> + + + , + ); + + await userEvent.click(page.getByText('Name')); + + await expect.element(page.getByRole('textbox')).toHaveFocus(); + }); +}); diff --git a/packages/react/src/components/popover/popover.test.tsx b/packages/react/src/components/popover/popover.test.tsx new file mode 100644 index 0000000..edfde06 --- /dev/null +++ b/packages/react/src/components/popover/popover.test.tsx @@ -0,0 +1,60 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it } from 'vitest'; + +import { clickOutside } from '~/test-utils'; + +import { Button } from '../button'; + +import * as Popover from './popover'; + +function Fixture() { + return ( + + + + + +
Popover content
+
+
+ ); +} + +describe('Popover', () => { + it('opens content on trigger click', async () => { + await render(); + + const trigger = page.getByRole('button', { name: 'Open popover' }); + await expect.element(trigger).toHaveAttribute('aria-expanded', 'false'); + + await userEvent.click(trigger); + + await expect.element(trigger).toHaveAttribute('aria-expanded', 'true'); + await expect.element(page.getByText('Popover content')).toBeVisible(); + }); + + it('closes on Escape and returns focus to the trigger', async () => { + await render(); + + const trigger = page.getByRole('button', { name: 'Open popover' }); + await userEvent.click(trigger); + await expect.element(page.getByText('Popover content')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + + await expect.element(page.getByText('Popover content')).not.toBeInTheDocument(); + await expect.element(trigger).toHaveFocus(); + }); + + it('closes on an outside click', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Open popover' })); + await expect.element(page.getByText('Popover content')).toBeVisible(); + + await clickOutside(); + + await expect.element(page.getByText('Popover content')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/radio/radio.test.tsx b/packages/react/src/components/radio/radio.test.tsx new file mode 100644 index 0000000..9179a33 --- /dev/null +++ b/packages/react/src/components/radio/radio.test.tsx @@ -0,0 +1,102 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { firstArg, pressKey } from '~/test-utils'; + +import { Radio } from './index'; + +function Fixture(props: React.ComponentProps) { + return ( + + + + + + ); +} + +describe('Radio', () => { + it('renders a radiogroup with radios', async () => { + await render(); + + await expect.element(page.getByRole('radiogroup')).toBeVisible(); + await expect.element(page.getByRole('radio', { name: 'Option A' })).toBeVisible(); + await expect.element(page.getByRole('radio', { name: 'Option B' })).toBeVisible(); + await expect.element(page.getByRole('radio', { name: 'Option C' })).toBeVisible(); + }); + + it('selects a radio on click', async () => { + await render(); + + await userEvent.click(page.getByRole('radio', { name: 'Option B' })); + + await expect.element(page.getByRole('radio', { name: 'Option B' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + await expect.element(page.getByRole('radio', { name: 'Option A' })).toHaveAttribute( + 'aria-checked', + 'false', + ); + }); + + it('moves selection with arrow keys', async () => { + await render(); + + (page.getByRole('radio', { name: 'Option A' }).element() as HTMLElement).focus(); + pressKey('ArrowDown'); + + await expect.element(page.getByRole('radio', { name: 'Option B' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + + pressKey('ArrowDown'); + await expect.element(page.getByRole('radio', { name: 'Option C' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('skips disabled items during keyboard navigation', async () => { + await render( + + + + + , + ); + + (page.getByRole('radio', { name: 'Option A' }).element() as HTMLElement).focus(); + pressKey('ArrowDown'); + + await expect.element(page.getByRole('radio', { name: 'Option C' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + await expect.element(page.getByRole('radio', { name: 'Option B' })).toHaveAttribute( + 'aria-checked', + 'false', + ); + }); + + it('respects defaultValue', async () => { + await render(); + + await expect.element(page.getByRole('radio', { name: 'Option B' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('calls onValueChange with the selected value', async () => { + const onValueChange = vi.fn(); + await render(); + + await userEvent.click(page.getByRole('radio', { name: 'Option C' })); + + expect(onValueChange).toHaveBeenCalledOnce(); + expect(firstArg(onValueChange)).toBe('c'); + }); +}); diff --git a/packages/react/src/components/scroll-area/scroll-area.test.tsx b/packages/react/src/components/scroll-area/scroll-area.test.tsx new file mode 100644 index 0000000..67b9ad4 --- /dev/null +++ b/packages/react/src/components/scroll-area/scroll-area.test.tsx @@ -0,0 +1,42 @@ +import { page } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it } from 'vitest'; + +import { ScrollArea } from './scroll-area'; + +describe('ScrollArea', () => { + it('renders children', async () => { + await render( + +
First item
+
, + ); + + await expect.element(page.getByText('First item')).toBeVisible(); + }); + + it('overflowing content is scrollable', async () => { + await render( + + {Array.from({ length: 50 }, (_, i) => ( +
{`Item ${i + 1}`}
+ ))} +
, + ); + + const last = page.getByText('Item 50'); + await expect.element(last).toBeInTheDocument(); + + // find the scrollable ancestor of the content + let scroller = (last.element() as HTMLElement).parentElement; + while (scroller && scroller.scrollHeight <= scroller.clientHeight) { + scroller = scroller.parentElement; + } + + expect(scroller).not.toBeNull(); + expect(scroller!.scrollHeight).toBeGreaterThan(scroller!.clientHeight); + + scroller!.scrollTop = 100; + expect(scroller!.scrollTop).toBeGreaterThan(0); + }); +}); diff --git a/packages/react/src/components/select/select.test.tsx b/packages/react/src/components/select/select.test.tsx new file mode 100644 index 0000000..caab6f6 --- /dev/null +++ b/packages/react/src/components/select/select.test.tsx @@ -0,0 +1,101 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { firstArg } from '~/test-utils'; + +import { Select } from './index'; + +function Fixture(props: React.ComponentProps) { + return ( + + + + + + Apple + Banana + Cherry + + + ); +} + +describe('Select', () => { + it('renders a combobox trigger', async () => { + await render(); + + await expect.element(page.getByRole('combobox', { name: 'Fruit' })).toBeVisible(); + }); + + it('opens a listbox on click and selects an option', async () => { + await render(); + + const trigger = page.getByRole('combobox', { name: 'Fruit' }); + await userEvent.click(trigger); + + await expect.element(page.getByRole('listbox')).toBeVisible(); + await userEvent.click(page.getByRole('option', { name: 'Banana' })); + + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + expect((trigger.element() as HTMLElement).textContent).toContain('Banana'); + }); + + it('opens with the keyboard and selects with ArrowDown + Enter', async () => { + await render(); + + const trigger = page.getByRole('combobox', { name: 'Fruit' }); + (trigger.element() as HTMLElement).focus(); + + await userEvent.keyboard('{ArrowDown}'); + await expect.element(page.getByRole('listbox')).toBeVisible(); + + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{Enter}'); + + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + expect((trigger.element() as HTMLElement).textContent).toContain('Banana'); + }); + + it('closes on Escape', async () => { + await render(); + + const trigger = page.getByRole('combobox', { name: 'Fruit' }); + await userEvent.click(trigger); + await expect.element(page.getByRole('listbox')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + // NOTE: focus does not return to the trigger — SelectContent + // intentionally preventDefaults onCloseAutoFocus (flagged delta). + }); + + it('does not open when the trigger is disabled', async () => { + await render( + + + + + + Apple + + , + ); + + const trigger = page.getByRole('combobox', { name: 'Fruit' }); + await expect.element(trigger).toBeDisabled(); + + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + }); + + it('calls onValueChange with the selected value', async () => { + const onValueChange = vi.fn(); + await render(); + + await userEvent.click(page.getByRole('combobox', { name: 'Fruit' })); + await userEvent.click(page.getByRole('option', { name: 'Cherry' })); + + expect(onValueChange).toHaveBeenCalledOnce(); + expect(firstArg(onValueChange)).toBe('cherry'); + }); +}); diff --git a/packages/react/src/components/separator/separator.test.tsx b/packages/react/src/components/separator/separator.test.tsx new file mode 100644 index 0000000..4a2cc2d --- /dev/null +++ b/packages/react/src/components/separator/separator.test.tsx @@ -0,0 +1,37 @@ +import { page } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it } from 'vitest'; + +import { Separator } from './separator'; + +describe('Separator', () => { + it('is not exposed to the accessibility tree when decorative', async () => { + await render(); + + await expect.element(page.getByRole('separator')).not.toBeInTheDocument(); + }); + + it('has the separator role when not decorative', async () => { + await render(); + + await expect.element(page.getByRole('separator')).toBeInTheDocument(); + }); + + it('exposes aria-orientation when not decorative', async () => { + await render(); + + await expect.element(page.getByRole('separator')).toHaveAttribute( + 'aria-orientation', + 'vertical', + ); + }); + + it('reports horizontal orientation', async () => { + await render(); + + // horizontal is the implicit default: it is either explicit or omitted + const separator = page.getByRole('separator'); + await expect.element(separator).toBeInTheDocument(); + expect(separator.element().getAttribute('aria-orientation')).not.toBe('vertical'); + }); +}); diff --git a/packages/react/src/components/sheet/sheet.test.tsx b/packages/react/src/components/sheet/sheet.test.tsx new file mode 100644 index 0000000..90c9c5b --- /dev/null +++ b/packages/react/src/components/sheet/sheet.test.tsx @@ -0,0 +1,87 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { firstArg } from '~/test-utils'; + +import { Button } from '../button'; + +import { Sheet } from './index'; + +function Fixture({ open, onOpenChange }: { open?: boolean; onOpenChange?: (open: boolean) => void }) { + return ( + + + + + + + Sheet title + + + + + Sheet description goes here. + + + ); +} + +describe('Sheet', () => { + it('opens a dialog with an accessible name and description', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Open sheet' })); + + const dialog = page.getByRole('dialog', { name: 'Sheet title' }); + await expect.element(dialog).toBeVisible(); + await expect.element(dialog).toContainElement( + page.getByText('Sheet description goes here.').element() as HTMLElement, + ); + }); + + it('moves focus inside the sheet on open', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Open sheet' })); + await expect.element(page.getByRole('dialog')).toBeVisible(); + + const dialog = page.getByRole('dialog').element() as HTMLElement; + expect(dialog.contains(document.activeElement)).toBe(true); + }); + + it('closes on Escape', async () => { + await render(); + + await userEvent.click(page.getByRole('button', { name: 'Open sheet' })); + await expect.element(page.getByRole('dialog')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes via the close button and returns focus to the trigger', async () => { + await render(); + + const trigger = page.getByRole('button', { name: 'Open sheet' }); + await userEvent.click(trigger); + await userEvent.click(page.getByRole('button', { name: 'Close' })); + + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + await expect.element(trigger).toHaveFocus(); + }); + + it('supports controlled open with onOpenChange', async () => { + const onOpenChange = vi.fn(); + const screen = await render(); + + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + + await screen.rerender(); + await expect.element(page.getByRole('dialog', { name: 'Sheet title' })).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + expect(onOpenChange).toHaveBeenCalled(); + expect(firstArg(onOpenChange)).toBe(false); + }); +}); diff --git a/packages/react/src/components/slider/slider.test.tsx b/packages/react/src/components/slider/slider.test.tsx new file mode 100644 index 0000000..adf8587 --- /dev/null +++ b/packages/react/src/components/slider/slider.test.tsx @@ -0,0 +1,84 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { clickAt, firstArg } from '~/test-utils'; + +import { Slider } from './slider'; + +describe('Slider', () => { + it('renders a slider with the current value', async () => { + await render(); + + const slider = page.getByRole('slider'); + await expect.element(slider).toBeVisible(); + await expect.element(slider).toHaveAttribute('aria-valuenow', '40'); + await expect.element(slider).toHaveAttribute('aria-valuemin', '0'); + await expect.element(slider).toHaveAttribute('aria-valuemax', '100'); + }); + + it('steps with ArrowRight and ArrowLeft', async () => { + await render(); + + const slider = page.getByRole('slider'); + (slider.element() as HTMLElement).focus(); + + await userEvent.keyboard('{ArrowRight}'); + await expect.element(slider).toHaveAttribute('aria-valuenow', '50'); + + await userEvent.keyboard('{ArrowLeft}'); + await userEvent.keyboard('{ArrowLeft}'); + await expect.element(slider).toHaveAttribute('aria-valuenow', '30'); + }); + + it('jumps to min/max with Home and End', async () => { + await render(); + + const slider = page.getByRole('slider'); + (slider.element() as HTMLElement).focus(); + + await userEvent.keyboard('{End}'); + await expect.element(slider).toHaveAttribute('aria-valuenow', '100'); + + await userEvent.keyboard('{Home}'); + await expect.element(slider).toHaveAttribute('aria-valuenow', '0'); + }); + + it('is disabled', async () => { + const onValueChange = vi.fn(); + await render(); + + const slider = page.getByRole('slider'); + const sliderEl = slider.element() as HTMLElement; + + // the disabled state is exposed on the slider root + const disabledAncestor = sliderEl.closest('[aria-disabled]') as HTMLElement | null; + expect(disabledAncestor?.getAttribute('aria-disabled')).toBe('true'); + + // pointer interaction does not change the value + const root = page.elementLocator(sliderEl.parentElement as Element); + await clickAt(root, (root.element() as HTMLElement).clientWidth - 2, 4); + + await expect.element(slider).toHaveAttribute('aria-valuenow', '40'); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('exposes aria-orientation for vertical sliders', async () => { + await render(); + + await expect.element(page.getByRole('slider')).toHaveAttribute('aria-orientation', 'vertical'); + }); + + it('calls onValueChange with the new value', async () => { + const onValueChange = vi.fn(); + await render(); + + const slider = page.getByRole('slider'); + (slider.element() as HTMLElement).focus(); + await userEvent.keyboard('{ArrowRight}'); + + expect(onValueChange).toHaveBeenCalled(); + // callback arity/shape varies between primitives (Radix passes an array) + expect([firstArg(onValueChange)].flat()[0]).toBe(50); + }); +}); diff --git a/packages/react/src/components/switch/switch.test.tsx b/packages/react/src/components/switch/switch.test.tsx new file mode 100644 index 0000000..105250f --- /dev/null +++ b/packages/react/src/components/switch/switch.test.tsx @@ -0,0 +1,51 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { firstArg } from '~/test-utils'; + +import { Switch } from './switch'; + +describe('Switch', () => { + it('toggles aria-checked on click', async () => { + await render(); + + const toggle = page.getByRole('switch', { name: 'Notifications' }); + await expect.element(toggle).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(toggle); + await expect.element(toggle).toHaveAttribute('aria-checked', 'true'); + + await userEvent.click(toggle); + await expect.element(toggle).toHaveAttribute('aria-checked', 'false'); + }); + + it('toggles with Space key', async () => { + await render(); + + const toggle = page.getByRole('switch', { name: 'Notifications' }); + (toggle.element() as HTMLElement).focus(); + + await userEvent.keyboard('{ }'); + await expect.element(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('does not toggle when disabled', async () => { + const onCheckedChange = vi.fn(); + await render(); + + await expect.element(page.getByRole('switch', { name: 'Notifications' })).toBeDisabled(); + + expect(onCheckedChange).not.toHaveBeenCalled(); + }); + + it('calls onCheckedChange with the new state', async () => { + const onCheckedChange = vi.fn(); + await render(); + + await userEvent.click(page.getByRole('switch', { name: 'Notifications' })); + + expect(onCheckedChange).toHaveBeenCalledOnce(); + expect(firstArg(onCheckedChange)).toBe(true); + }); +}); diff --git a/packages/react/src/components/tabs/tabs.test.tsx b/packages/react/src/components/tabs/tabs.test.tsx new file mode 100644 index 0000000..6736ac1 --- /dev/null +++ b/packages/react/src/components/tabs/tabs.test.tsx @@ -0,0 +1,71 @@ +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it } from 'vitest'; + +import { Tabs } from './index'; + +function Fixture() { + return ( + + + Tab A + Tab B + Tab C + + Content A + Content B + Content C + + ); +} + +describe('Tabs', () => { + it('shows the default tab panel', async () => { + await render(); + + await expect.element(page.getByRole('tab', { name: 'Tab A' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect.element(page.getByRole('tabpanel')).toHaveTextContent('Content A'); + }); + + it('activates a tab on click and shows its panel', async () => { + await render(); + + await userEvent.click(page.getByRole('tab', { name: 'Tab B' })); + + await expect.element(page.getByRole('tab', { name: 'Tab B' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect.element(page.getByRole('tab', { name: 'Tab A' })).toHaveAttribute( + 'aria-selected', + 'false', + ); + await expect.element(page.getByRole('tabpanel')).toHaveTextContent('Content B'); + }); + + it('moves focus with arrow keys', async () => { + await render(); + + await userEvent.click(page.getByRole('tab', { name: 'Tab A' })); + await userEvent.keyboard('{ArrowRight}'); + + await expect.element(page.getByRole('tab', { name: 'Tab B' })).toHaveFocus(); + }); + + it('cannot select a disabled tab', async () => { + await render(); + + await userEvent.click(page.getByRole('tab', { name: 'Tab A' })); + // Tab C is disabled: arrow navigation should skip it and wrap to Tab A + await userEvent.keyboard('{ArrowLeft}'); + + await expect.element(page.getByRole('tab', { name: 'Tab C' })).toHaveAttribute( + 'aria-selected', + 'false', + ); + await expect.element(page.getByRole('tab', { name: 'Tab C' })).not.toHaveFocus(); + }); +}); diff --git a/packages/react/src/components/textarea/textarea.test.tsx b/packages/react/src/components/textarea/textarea.test.tsx new file mode 100644 index 0000000..d3f2f6a --- /dev/null +++ b/packages/react/src/components/textarea/textarea.test.tsx @@ -0,0 +1,47 @@ +import { createRef } from 'react'; +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Textarea } from './textarea'; + +describe('Textarea', () => { + it('renders a textbox', async () => { + await render(