diff --git a/.docker/nginx.conf b/.docker/nginx.conf index 123ee93..3a5d04d 100644 --- a/.docker/nginx.conf +++ b/.docker/nginx.conf @@ -17,8 +17,7 @@ server { # cached indefinitely. `^~` stops nginx from also testing the regex block # below, which would otherwise claim any hashed `.json` asset. location ^~ /assets/ { - expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "public, max-age=31536000, immutable" always; try_files $uri =404; } diff --git a/.storybook/preview.css b/.storybook/preview.css index 1499df4..07a150b 100644 --- a/.storybook/preview.css +++ b/.storybook/preview.css @@ -3,6 +3,16 @@ @plugin "@tailwindcss/typography"; +/* + * The overlays (Dialog, AlertDialog, Sheet, Popover, Select, Combobox, + * Tooltip) are shadcn-derived and their enter/exit states are written with + * `animate-in`, `fade-in-0`, `zoom-in-95` and `slide-in-from-*`. None of those + * are Tailwind core utilities — they come from this plugin. Without it every + * overlay still renders, but appears and disappears with no transition, so the + * showcase would misrepresent them. + */ +@import "tw-animate-css"; + /* * The same content configuration a consuming app needs. Spec 001 recorded this * as an unverified precondition: the library ships utility classes as source diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 35f8971..a732517 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,5 +1,6 @@ import type { Preview } from "@storybook/react-vite"; import type { ReactNode } from "react"; +import { LanguageProvider, TooltipProvider, de, en } from "../src/index.ts"; import "./preview.css"; const preview: Preview = { @@ -8,12 +9,18 @@ const preview: Preview = { layout: "padded", }, decorators: [ - // Stand in for the consuming app's root element, which is where the brand - // body font and base colours are applied. + // Stand in for the consuming app's root. Both providers are genuine + // preconditions, not showcase scaffolding: `Tooltip` throws outside a + // `TooltipProvider`, and `LanguageSwitch`, `TranslateDialog` and `Sidebar` + // all call `useLanguage()`, which throws outside a `LanguageProvider`. (Story: () => ReactNode) => ( -
- -
+ + +
+ +
+
+
), ], }; diff --git a/Dockerfile b/Dockerfile index 94d616e..0fd22c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,5 +34,3 @@ COPY --from=build /app/storybook-static /usr/share/nginx/html EXPOSE 80 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ - CMD wget -q --spider http://localhost/healthz || exit 1 diff --git a/README.md b/README.md index 476ec97..4c2e839 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,22 @@ typography plugin that `MarkdownView` relies on: @import "tailwindcss"; @import "@open-elements/ui/src/styles/brand.css"; @plugin "@tailwindcss/typography"; +@import "tw-animate-css"; @source "../node_modules/@open-elements/ui/src"; ``` -Without the `@source` line, components render unstyled — task list checkboxes pick up a `prose` -bullet, for instance. The [component showcase](#component-showcase) uses the same configuration and -asserts the result, so a break in this contract surfaces there. +Each line is load-bearing: + +- **`@source`** — this package ships raw `.tsx`, so its utility classes reach your app as source + text. Without the scan they never become CSS, and components render unstyled: task list + checkboxes pick up a `prose` bullet, for instance. +- **`@tailwindcss/typography`** — `MarkdownView` and `MarkdownEditor` hardcode `prose prose-sm`. +- **`tw-animate-css`** — every overlay (`Dialog`, `AlertDialog`, `Sheet`, `Popover`, `Select`, + `Combobox`, `Tooltip`) writes its enter and exit states with `animate-in`, `fade-in-0`, + `zoom-in-95` and `slide-in-from-*`, none of which are Tailwind core utilities. + +The [component showcase](#component-showcase) uses exactly this configuration and asserts the +result, so a break in this contract surfaces there. ## Translations @@ -73,8 +83,13 @@ indirectly: the toolbar allowlist, the task-list creation gate under actual keys checkbox lifecycle after actual clicks, and that the Tailwind utilities the components rely on resolve to real styles. They are additive; the vitest suites are unchanged. -`MarkdownEditor` and `MarkdownView` establish the pattern. The remaining components follow -incrementally. +Every exported component has stories, grouped in the sidebar as Primitives, Forms, Overlays, Data, +Actions, Navigation and Markdown. + +Rendering the library for real turned up defects the unit tests cannot see — an undefined +`--radius`, a non-existent `text-oe-gray`, unlabelled icon buttons and a set of brand tokens that +miss WCAG AA. They are catalogued in [docs/showcase-findings.md](docs/showcase-findings.md); none +have been changed in `src/`. Deployment of the showcase is documented in [docs/showcase-deployment.md](docs/showcase-deployment.md). diff --git a/docs/showcase-findings.md b/docs/showcase-findings.md new file mode 100644 index 0000000..e58df4b --- /dev/null +++ b/docs/showcase-findings.md @@ -0,0 +1,164 @@ +# What the showcase found + +Rendering every component with a real Tailwind build, driving it in a real browser and running axe +over each story turned up a set of defects that neither the vitest suites nor the type checker can +see. None of them are showcase bugs — they are what the consuming applications are already shipping. + +Nothing here has been changed in `src/`. This is the list, with what it would take to fix each one. + +Reproduce with `pnpm storybook` and the **Accessibility** panel, or `pnpm build-storybook` and run +axe over the static build. + +## Styling + +### `--radius` is referenced but never defined + +`input-group.tsx` sizes two recipes with `rounded-[calc(var(--radius)-5px)]` +(`inputGroupAddonVariants`, `inputGroupButtonVariants`). Tailwind v4 ships `--radius-xs` through +`--radius-xl`, but not a bare `--radius`, and `src/styles/brand.css` does not define one either. The +`calc()` is therefore invalid and the whole `border-radius` declaration is dropped — those corners +are square, not rounded. + +*Fix:* add `--radius: 0.625rem` (the shadcn default) to the `@theme` block in `brand.css`. + +### `text-oe-gray` is not a token + +`tag-chips.tsx` styles its optional heading with `text-oe-gray`. The palette defines `oe-gray-mid` +and `oe-gray-light`; there is no `oe-gray`. The class compiles to nothing, so the heading inherits +whatever colour surrounds it. + +*Fix:* `text-oe-gray-mid`, or define the token. + +### Overlay animations need a plugin the library does not declare + +Every overlay — `Dialog`, `AlertDialog`, `Sheet`, `Popover`, `Select`, `Combobox`, `Tooltip` — writes +its enter and exit states with `animate-in`, `animate-out`, `fade-in-0`, `zoom-in-95` and +`slide-in-from-*`. None of those are Tailwind core utilities; they come from `tw-animate-css` +(`tailwindcss-animate` on v3). Without it the overlays still work, but appear and disappear +instantly, and a `Sheet` never slides. + +The showcase imports `tw-animate-css` in `.storybook/preview.css`. A consuming app has to do the +same — it is as much a precondition as the `@source` line, and it is not written down anywhere. + +## Colour contrast + +77 contrast violations across 26 components, almost all of them tracing back to three token +choices. Measured against WCAG AA for normal text (4.5:1): + +| Pair | Ratio | Verdict | +|---|---|---| +| `muted-foreground` / `oe-gray-mid` `#b0aea5` on `background` | 2.22:1 | fails | +| `primary-foreground` on `primary` `#5cba9e` | 2.34:1 | fails | +| `oe-green` `#5cba9e` as text on `background` | 2.34:1 | fails | +| `destructive` / `oe-red` `#e63277`, either direction | 4.13:1 | large text only | + +`muted-foreground` is the widest-reaching of the three. It is the colour of `CardDescription`, +`TableCaption`, `Select` placeholders, `Calendar` weekday headers, `InputGroup` addon text, +`TablePagination`'s counts, `DetailField`'s `
` labels, `CapabilityStatus`'s status line and +`UserMultiSelect`'s placeholder — all of it currently below the threshold. + +*Fix:* darken `--color-muted-foreground` (around `#6f6d66` reaches 4.5:1 on white), and either +darken `--color-primary` or stop putting white text on it. `destructive` clears AA for large text +only, so it is fine on a heading and not on a 14px error message — `TagForm`'s validation messages +use it at `text-sm`. + +### The contrast helper is not a contrast calculation + +`tag-chips.tsx`, `tag-multi-select.tsx` and `tag-form.tsx` each carry the same `getContrastColor`: + +```ts +const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; +return luminance > 0.5 ? "#1A1A1A" : "#FFFFFF"; +``` + +That is a perceived-brightness threshold, not a WCAG contrast ratio. It gets the extremes right — +`#F1E34B` correctly gets dark text at 13.12:1 — but a mid-tone lands on white text that fails: the +`#5CBA9E` chip comes out at 2.34:1 and `#E63277` at 4.13:1. + +*Fix:* pick whichever of black and white has the higher computed ratio against the background, using +relative luminance as WCAG defines it. Worth extracting to `lib/` while doing it — the function is +currently copy-pasted three times, along with `isValidHex`. + +`UserAvatar` has the same problem from a fixed palette: white initials on `bg-green-500` (2.28:1), +`bg-teal-500` (2.49:1) and `bg-orange-500` (2.80:1) all fail. The palette needs the 600 or 700 +shades. + +## Accessible names + +25 `button-name` violations. Every one is an icon-only button whose only description is a tooltip — +and a Radix tooltip supplies `aria-describedby` when it opens, never an accessible name. To a screen +reader these are unlabelled buttons. + +| Component | Buttons | +|---|---| +| `DetailField` | copy, open, email, call — all four | +| `TagForm` | the twelve palette swatches | +| `Combobox` | `ComboboxTrigger` and `ComboboxChipRemove` (so also `TagMultiSelect`) | +| `UserMultiSelect` | the popover trigger | +| `Sidebar` / `UserSection` | the avatar button | + +*Fix:* `aria-label` on each, reusing the text already passed for the tooltip. `TooltipIconButton` +already does exactly this and is the model to follow. + +### `SelectTrigger` cannot be named by its contents + +`SelectTrigger` renders ` + + + + + + ); + }, + parameters: { + docs: { + description: { + story: + "Inside a popover or a card the calendar drops its own background — it detects the " + + "surrounding slot rather than taking a prop for it.", + }, + }, + }, +}; + +export const InACard: Story = { + render: (args) => ( + + + + + + ), +}; diff --git a/stories/forms/combobox.stories.tsx b/stories/forms/combobox.stories.tsx new file mode 100644 index 0000000..631112a --- /dev/null +++ b/stories/forms/combobox.stories.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + Label, + useComboboxAnchor, +} from "../../src/index.ts"; + +const STAGES = ["Lead", "Qualified", "Proposal sent", "Negotiation", "Won", "Lost"]; + +const meta: Meta = { + title: "Forms/Combobox", + component: Combobox, + parameters: { + docs: { + description: { + component: + "A searchable picker built on Base UI. Two shapes are in use in this library: a plain " + + "input with a dropdown, and the chips variant that `TagMultiSelect` builds on.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Single: Story = { + render: function Single() { + const [value, setValue] = useState(null); + return ( +
+ + + + + No stage matches. + + {(stage: string) => ( + + {stage} + + )} + + + +

+ value: {value ?? "—"} +

+
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Both the field and its trigger expose role="combobox", so address the + // field by its placeholder. + const input = canvas.getByPlaceholderText("Search stages…"); + + await userEvent.click(input); + await userEvent.type(input, "Prop"); + + const option = await screen.findByRole("option", { name: "Proposal sent" }); + await userEvent.click(option); + + await waitFor(() => expect(canvas.getByTestId("selected")).toHaveTextContent("Proposal sent")); + }, +}; + +export const Chips: Story = { + render: function Chips() { + const [value, setValue] = useState(["Qualified"]); + const anchorRef = useComboboxAnchor(); + return ( +
+ + + + {value.map((stage) => ( + {stage} + ))} + + + + No stage matches. + + {(stage: string) => ( + + {stage} + + )} + + + +

+ {value.join(", ") || "—"} +

+
+ ); + }, + parameters: { + docs: { + description: { + story: + "The chips variant anchors the popup to the chip container rather than the input, so the " + + "dropdown stays put as chips wrap onto a second line. `useComboboxAnchor()` is the ref " + + "for that.", + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId("selected")).toHaveTextContent("Qualified"); + + await userEvent.click(canvas.getByPlaceholderText("Add a stage…")); + await userEvent.click(await screen.findByRole("option", { name: "Won" })); + + await waitFor(() => expect(canvas.getByTestId("selected")).toHaveTextContent("Qualified, Won")); + }, +}; + +export const NoMatches: Story = { + render: Single.render, + parameters: { + docs: { + description: { + story: + "`ComboboxEmpty` only shows itself when the list has nothing left, driven by " + + "`group-data-empty` on the popup.", + }, + }, + }, + play: async ({ canvasElement }) => { + const input = within(canvasElement).getByPlaceholderText("Search stages…"); + await userEvent.click(input); + await userEvent.type(input, "zzzz"); + await expect(await screen.findByText("No stage matches.")).toBeVisible(); + }, +}; diff --git a/stories/forms/input-group.stories.tsx b/stories/forms/input-group.stories.tsx new file mode 100644 index 0000000..912bed9 --- /dev/null +++ b/stories/forms/input-group.stories.tsx @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CreditCard, Search, Send } from "lucide-react"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupText, + InputGroupTextarea, +} from "../../src/index.ts"; + +const meta: Meta = { + title: "Forms/InputGroup", + component: InputGroup, + parameters: { + docs: { + description: { + component: + "A field with attachments. `InputGroupAddon` takes an `align` of `inline-start`, " + + "`inline-end`, `block-start` or `block-end`, and the group reshapes itself around " + + "whichever are present — focus and error rings included, via `has-[…]` selectors on the " + + "wrapper rather than props.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + + + + ), + play: async ({ canvasElement }) => { + const input = within(canvasElement).getByRole("textbox"); + await userEvent.type(input, "Nordwind"); + await expect(input).toHaveValue("Nordwind"); + }, +}; + +export const InlineEndButton: Story = { + render: (args) => ( + + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Clicking the addon focuses the field; clicking a button inside it does not. + await userEvent.click(canvas.getByRole("button", { name: "Send" })); + await expect(canvas.getByRole("textbox")).not.toHaveFocus(); + }, +}; + +export const TextAddons: Story = { + render: (args) => ( +
+ + + https:// + + + + + + + EUR + + + + + + + + + Visa + + +
+ ), +}; + +export const BlockAddons: Story = { + render: (args) => ( + + + Internal note + + + + + Save note + + + + ), + parameters: { + docs: { + description: { + story: + "Block addons stack the group vertically — the layout switch is triggered by the addon's " + + "`data-align`, not by a prop on the group.", + }, + }, + }, + play: async ({ canvasElement }) => { + const group = canvasElement.querySelector('[data-slot="input-group"]'); + await expect(getComputedStyle(group!).flexDirection).toBe("column"); + }, +}; + +export const Invalid: Story = { + render: (args) => ( + + + https:// + + + + ), + parameters: { + docs: { + description: { + story: + "The whole group turns destructive when the control inside it is `aria-invalid` — the " + + "border lives on the wrapper, so it has to react to the child's state.", + }, + }, + }, +}; + +export const Disabled: Story = { + render: (args) => ( + + + + + + + ), +}; diff --git a/stories/forms/select.stories.tsx b/stories/forms/select.stories.tsx new file mode 100644 index 0000000..139ade5 --- /dev/null +++ b/stories/forms/select.stories.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { + Label, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "../../src/index.ts"; +import { expectVisible } from "../support/overlay.ts"; + +const meta: Meta = { + title: "Forms/Select", + component: Select, + argTypes: { + disabled: { control: "boolean" }, + }, + parameters: { + docs: { + description: { + component: + "A single-choice dropdown. The listbox is portalled, so a play function looks for it on " + + "the screen rather than inside the story canvas.\n\n" + + '`SelectTrigger` renders a ` + + + + Delete Open Elements GmbH? + + Its contacts stay, but the link to them is lost. This cannot be undone. + + + + Cancel + + Delete + + + + + ), + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Delete company" })); + + const dialog = await screen.findByRole("alertdialog"); + // Unlike Dialog, there is no dismiss affordance beyond the two choices. + await expect(within(dialog).getAllByRole("button")).toHaveLength(2); + + await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitForElementToBeRemoved(() => screen.queryByRole("alertdialog")); + }, +}; + +export const WithMedia: Story = { + render: (args) => ( + + + + + + + + + + Discard this draft? + + The note has unsaved changes that will be lost. + + + + Keep editing + Discard + + + + ), + parameters: { + docs: { + description: { + story: + "An `AlertDialogMedia` slot moves the icon into its own column and re-flows the header " + + "grid around it — the header rules key off `has-data-[slot=alert-dialog-media]`.", + }, + }, + }, + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Discard draft" })); + const dialog = await screen.findByRole("alertdialog"); + await expect(dialog.querySelector('[data-slot="alert-dialog-media"]')).not.toBeNull(); + }, +}; + +export const Small: Story = { + render: (args) => ( + + + + + + + Sign out? + You will need to log in again. + + + Stay + Sign out + + + + ), + parameters: { + docs: { + description: { + story: + '`size="sm"` narrows the box and lays the two actions out as an even two-column grid ' + + "instead of a right-aligned row.", + }, + }, + }, + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Sign out" })); + const dialog = await screen.findByRole("alertdialog"); + await expect(dialog).toHaveAttribute("data-size", "sm"); + }, +}; diff --git a/stories/overlays/delete-confirm-dialog.stories.tsx b/stories/overlays/delete-confirm-dialog.stories.tsx new file mode 100644 index 0000000..af9642c --- /dev/null +++ b/stories/overlays/delete-confirm-dialog.stories.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { Button, DeleteConfirmDialog } from "../../src/index.ts"; +import { delay } from "../support/fixtures.ts"; +import { expectVisible } from "../support/overlay.ts"; + +interface DeleteStoryProps { + readonly latencyMs: number; + readonly failsWith: string | null; +} + +/** + * The dialog is prop-driven — it owns nothing but its own loading flag — so a + * story has to hold `open` and hand it a confirm handler. + */ +function DeleteConfirmHarness({ latencyMs, failsWith }: DeleteStoryProps) { + const [open, setOpen] = useState(false); + const [deleted, setDeleted] = useState(false); + + return ( +
+ + {deleted && ( +

+ Deleted. +

+ )} + { + await delay(latencyMs); + setDeleted(true); + setOpen(false); + }} + /> +
+ ); +} + +const meta: Meta = { + title: "Overlays/DeleteConfirmDialog", + render: (args) => , + args: { latencyMs: 800, failsWith: null }, + argTypes: { + latencyMs: { + control: { type: "range", min: 0, max: 4000, step: 100 }, + description: "How long the delete appears to take.", + }, + failsWith: { + control: "text", + description: + "Set a message to put the dialog into its error shape: the description is replaced, the " + + "title falls back to `errorTitle`, and only a dismiss button remains.", + }, + }, + parameters: { + docs: { + description: { + component: + "A confirmation for destructive actions, built on `AlertDialog`. It spins while " + + "`onConfirm` is in flight and refuses a second click until that promise settles.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Delete company" })); + + const dialog = await screen.findByRole("alertdialog"); + await expectVisible(within(dialog).getByText("Delete Open Elements GmbH?")); + + await userEvent.click(within(dialog).getByRole("button", { name: "Delete" })); + + // Disabled for the whole in-flight window, so a double click cannot delete twice. + await waitFor(() => + expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled(), + ); + + await waitFor(() => expect(canvas.getByTestId("outcome")).toBeVisible(), { timeout: 4000 }); + }, +}; + +export const Cancelled: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Delete company" })); + + const dialog = await screen.findByRole("alertdialog"); + await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + + await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); + await expect(canvas.queryByTestId("outcome")).toBeNull(); + }, +}; + +export const Failed: Story = { + args: { failsWith: "The company is referenced by three open invoices." }, + parameters: { + docs: { + description: { + story: + "With an `error` set the dialog stops offering the destructive action at all — there is " + + "one button left, and it only dismisses.", + }, + }, + }, + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Delete company" })); + + const dialog = await screen.findByRole("alertdialog"); + await expectVisible(within(dialog).getByText("Could not delete")); + await expectVisible( + within(dialog).getByText("The company is referenced by three open invoices."), + ); + await expect(within(dialog).getAllByRole("button")).toHaveLength(1); + }, +}; diff --git a/stories/overlays/dialog.stories.tsx b/stories/overlays/dialog.stories.tsx new file mode 100644 index 0000000..c1bab5b --- /dev/null +++ b/stories/overlays/dialog.stories.tsx @@ -0,0 +1,139 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + expect, + screen, + userEvent, + waitFor, + waitForElementToBeRemoved, + within, +} from "storybook/test"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Input, + Label, +} from "../../src/index.ts"; +import { expectVisible } from "../support/overlay.ts"; + +const meta: Meta = { + title: "Overlays/Dialog", + component: Dialog, + parameters: { + docs: { + description: { + component: + "A modal dialog. The content is portalled to `document.body`, so a play function has to " + + "query the whole screen rather than the story canvas.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + + + + Edit contact + Changes are saved when you confirm. + +
+ + +
+ + + + + + + + +
+
+ ), + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Edit contact" })); + + const dialog = await screen.findByRole("dialog"); + await expectVisible(within(dialog).getByText("Edit contact")); + + // The title and description are wired to the dialog, so it announces itself. + await expect(dialog).toHaveAttribute("aria-labelledby"); + await expect(dialog).toHaveAttribute("aria-describedby"); + + await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitForElementToBeRemoved(() => screen.queryByRole("dialog")); + }, +}; + +export const ClosesOnEscape: Story = { + render: Default.render, + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Edit contact" })); + await screen.findByRole("dialog"); + await userEvent.keyboard("{Escape}"); + await waitForElementToBeRemoved(() => screen.queryByRole("dialog")); + }, +}; + +export const WithoutCloseButton: Story = { + render: (args) => ( + + + + + + + No corner cross + + Use this when the only way out should be an explicit choice in the footer. + + + + + + ), + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Open" })); + const dialog = await screen.findByRole("dialog"); + // The corner cross is gone; the footer's own Close button is not. + await expect(within(dialog).getAllByRole("button", { name: "Close" })).toHaveLength(1); + // Worth knowing: `DialogFooter`'s built-in close renders the Radix primitive + // directly, so unlike the exported `DialogClose` it carries no + // `data-slot="dialog-close"` for a consuming app to hook styling onto. + await expect(dialog.querySelectorAll('[data-slot="dialog-close"]')).toHaveLength(0); + }, +}; + +export const Controlled: Story = { + args: { open: true }, + argTypes: { open: { control: "boolean" } }, + render: (args) => ( + + + + Controlled from the Controls panel + Toggle the `open` arg to show and hide this dialog. + + + + ), + play: async () => { + await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible()); + }, +}; diff --git a/stories/overlays/popover.stories.tsx b/stories/overlays/popover.stories.tsx new file mode 100644 index 0000000..2076395 --- /dev/null +++ b/stories/overlays/popover.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitForElementToBeRemoved, within } from "storybook/test"; +import { + Button, + Input, + Label, + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "../../src/index.ts"; +import { expectVisible } from "../support/overlay.ts"; + +const meta: Meta = { + title: "Overlays/Popover", + component: Popover, + parameters: { + docs: { + description: { + component: + "A non-modal panel anchored to its trigger. Positioning is Radix's; the component only " + + "supplies the surface, a default width of 18rem and the side-aware enter animation.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + + + + Dimensions + Applied to the selected block. + +
+ + +
+
+
+ ), + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole("button", { name: "Set dimensions" }); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + + await userEvent.click(trigger); + const content = await screen.findByText("Dimensions"); + await expectVisible(content); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + + await userEvent.keyboard("{Escape}"); + await waitForElementToBeRemoved(() => screen.queryByText("Dimensions")); + }, +}; + +export const Sides: Story = { + render: (args) => ( +
+ {(["top", "right", "bottom", "left"] as const).map((side) => ( + + + + + + Anchored to the {side}. + + + ))} +
+ ), + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "right" })); + const content = await screen.findByText("Anchored to the right."); + // Radix records the resolved side, which may flip if there is no room. + await expect(content.closest("[data-side]")).toHaveAttribute("data-side"); + }, +}; + +export const Controlled: Story = { + args: { open: true }, + argTypes: { open: { control: "boolean" } }, + render: (args) => ( +
+ + + + + + Toggle the `open` arg to show and hide this. + + +
+ ), +}; diff --git a/stories/overlays/sheet.stories.tsx b/stories/overlays/sheet.stories.tsx new file mode 100644 index 0000000..64d771a --- /dev/null +++ b/stories/overlays/sheet.stories.tsx @@ -0,0 +1,129 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + expect, + screen, + userEvent, + waitFor, + waitForElementToBeRemoved, + within, +} from "storybook/test"; +import { + Button, + Input, + Label, + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "../../src/index.ts"; +import { expectVisible } from "../support/overlay.ts"; + +const SIDES = ["right", "left", "top", "bottom"] as const; + +const meta: Meta = { + title: "Overlays/Sheet", + component: Sheet, + parameters: { + docs: { + description: { + component: + "A panel that slides in from an edge. Which edge is a prop on `SheetContent`, not on the " + + "root, because the root has no DOM of its own.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + + + + Edit profile + Changes apply immediately. + +
+ + +
+ + + + + +
+
+ ), + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Edit profile" })); + const sheet = await screen.findByRole("dialog"); + await expectVisible(within(sheet).getByText("Edit profile")); + await userEvent.click(within(sheet).getByRole("button", { name: "Done" })); + await waitForElementToBeRemoved(() => screen.queryByRole("dialog")); + }, +}; + +export const Sides: Story = { + render: (args) => ( +
+ {SIDES.map((side) => ( + + + + + + + side="{side}" + + Left and right take three quarters of the width; top and bottom size to their + content. + + + + + ))} +
+ ), + play: async ({ canvasElement }) => { + // A left sheet is anchored to the left edge, which is what `side` actually controls. + await userEvent.click(within(canvasElement).getByRole("button", { name: "From left" })); + const sheet = await screen.findByRole("dialog"); + // It slides in from off-screen, so wait for it to land rather than catching + // it mid-transition. + await waitFor(() => expect(sheet.getBoundingClientRect().left).toBe(0)); + }, +}; + +export const WithoutCloseButton: Story = { + render: (args) => ( + + + + + + + No corner cross + Escape and the overlay still dismiss it. + + + + ), + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Open" })); + const sheet = await screen.findByRole("dialog"); + await expect(within(sheet).queryByRole("button", { name: "Close" })).toBeNull(); + await userEvent.keyboard("{Escape}"); + await waitForElementToBeRemoved(() => screen.queryByRole("dialog")); + }, +}; diff --git a/stories/overlays/tooltip.stories.tsx b/stories/overlays/tooltip.stories.tsx new file mode 100644 index 0000000..e24bfc2 --- /dev/null +++ b/stories/overlays/tooltip.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Info } from "lucide-react"; +import { expect, screen, userEvent, within } from "storybook/test"; +import { Button, Tooltip, TooltipContent, TooltipTrigger } from "../../src/index.ts"; +import { expectVisible } from "../support/overlay.ts"; + +const meta: Meta = { + title: "Overlays/Tooltip", + component: Tooltip, + parameters: { + docs: { + description: { + component: + "A hover/focus hint. It must be inside a `TooltipProvider` — Radix throws otherwise — so " + + "the showcase supplies one globally, exactly as a consuming app does at its root.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( +
+ + + + + Exports the current view as CSV + +
+ ), + play: async ({ canvasElement }) => { + await userEvent.hover(within(canvasElement).getByRole("button", { name: "Hover me" })); + await expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Exports the current view as CSV", + ); + }, +}; + +export const OpensOnFocus: Story = { + render: Default.render, + parameters: { + docs: { + description: { + story: + "Keyboard users get the same hint — the trigger reveals it on focus, not just hover.", + }, + }, + }, + play: async ({ canvasElement }) => { + within(canvasElement).getByRole("button", { name: "Hover me" }).focus(); + await expectVisible(await screen.findByRole("tooltip")); + }, +}; + +export const Sides: Story = { + render: (args) => ( +
+ {(["top", "right", "bottom", "left"] as const).map((side) => ( + + + + + Shown on the {side} + + ))} +
+ ), +}; + +export const OnPlainText: Story = { + render: (args) => ( +

+ The invoice is{" "} + + + + overdue + + + Payment was due 14 days ago + {" "} + and a reminder has been sent. +

+ ), + parameters: { + docs: { + description: { + story: + "Any element can be the trigger via `asChild`, but it has to be focusable or keyboard " + + "users never see the hint — hence `tabIndex={0}` on the span.", + }, + }, + }, +}; diff --git a/stories/overlays/translate-dialog.stories.tsx b/stories/overlays/translate-dialog.stories.tsx new file mode 100644 index 0000000..9781fe0 --- /dev/null +++ b/stories/overlays/translate-dialog.stories.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { Button, TranslateDialog } from "../../src/index.ts"; +import type { TranslateResult } from "../../src/index.ts"; +import { TRANSLATE_DIALOG_TRANSLATIONS, delay } from "../support/fixtures.ts"; +import { expectVisible } from "../support/overlay.ts"; + +const SOURCE_TEXT = + "Die Rechnung ist seit vierzehn Tagen überfällig; eine Erinnerung wurde bereits versendet."; + +interface TranslateStoryProps { + readonly sourceText: string; + readonly latencyMs: number; + readonly fails: boolean; +} + +function TranslateHarness({ sourceText, latencyMs, fails }: TranslateStoryProps) { + const [open, setOpen] = useState(false); + + async function onTranslate(text: string, targetLanguage: string): Promise { + await delay(latencyMs); + if (fails) throw new Error("Translation backend unavailable"); + return { + translatedText: `[${targetLanguage}] The invoice has been overdue for fourteen days; a reminder has already been sent.\n\n(source: ${text.slice(0, 24)}…)`, + }; + } + + return ( +
+

{sourceText}

+ + +
+ ); +} + +const meta: Meta = { + title: "Overlays/TranslateDialog", + render: (args) => , + args: { sourceText: SOURCE_TEXT, latencyMs: 900, fails: false }, + argTypes: { + sourceText: { control: "text" }, + latencyMs: { control: { type: "range", min: 0, max: 4000, step: 100 } }, + fails: { control: "boolean", description: "Make the translation request reject." }, + }, + parameters: { + docs: { + description: { + component: + "Fires `onTranslate` every time `open` flips to true, targeting whichever language " + + "`useLanguage()` reports. The showcase's provider is fixed to English, so the mock is " + + 'called with `"en"`.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Translate" })); + + const dialog = await screen.findByRole("dialog"); + await expectVisible(within(dialog).getByTestId("translate-dialog-loading")); + // Nothing to copy while the request is in flight. + await expect(within(dialog).getByTestId("translate-dialog-copy")).toBeDisabled(); + + const result = await within(dialog).findByTestId("translate-dialog-result", undefined, { + timeout: 5000, + }); + await expect(result).toHaveTextContent("The invoice has been overdue"); + await expect(within(dialog).getByTestId("translate-dialog-copy")).toBeEnabled(); + }, +}; + +export const Failed: Story = { + args: { fails: true, latencyMs: 400 }, + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Translate" })); + + const dialog = await screen.findByRole("dialog"); + const error = await within(dialog).findByTestId("translate-dialog-error", undefined, { + timeout: 5000, + }); + await expect(error).toHaveTextContent(TRANSLATE_DIALOG_TRANSLATIONS.error); + await expect(within(dialog).getByTestId("translate-dialog-copy")).toBeDisabled(); + }, +}; + +export const SlowRequest: Story = { + args: { latencyMs: 3000 }, + parameters: { + docs: { + description: { + story: "Long enough to sit and look at the loading state.", + }, + }, + }, + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Translate" })); + const dialog = await screen.findByRole("dialog"); + await waitFor(() => + expect(within(dialog).getByTestId("translate-dialog-loading")).toBeVisible(), + ); + }, +}; diff --git a/stories/primitives/badge.stories.tsx b/stories/primitives/badge.stories.tsx new file mode 100644 index 0000000..e57433a --- /dev/null +++ b/stories/primitives/badge.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Check, CircleAlert } from "lucide-react"; +import { expect, within } from "storybook/test"; +import { Badge } from "../../src/index.ts"; + +const VARIANTS = ["default", "secondary", "destructive", "outline", "ghost", "link"] as const; + +const meta: Meta = { + title: "Primitives/Badge", + component: Badge, + args: { children: "Active" }, + argTypes: { + variant: { control: "select", options: VARIANTS }, + asChild: { table: { disable: true } }, + }, + parameters: { + docs: { + description: { + component: + "A small status pill. Renders a `` by default; `asChild` swaps in an anchor, and " + + "the hover styles are written with `[a&]:` so they only apply once it is a link.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Variants: Story = { + render: (args) => ( +
+ {VARIANTS.map((variant) => ( + + {variant} + + ))} +
+ ), + parameters: { controls: { exclude: ["variant", "children"] } }, +}; + +export const WithIcon: Story = { + args: { children: undefined }, + render: (args) => ( +
+ + + Verified + + + + Overdue + +
+ ), +}; + +export const AsLink: Story = { + args: { asChild: true, variant: "outline", children: undefined }, + render: (args) => ( + + + Documentation + + + ), + play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByRole("link")).toHaveAttribute("data-slot", "badge"); + }, +}; diff --git a/stories/primitives/button.stories.tsx b/stories/primitives/button.stories.tsx new file mode 100644 index 0000000..747d313 --- /dev/null +++ b/stories/primitives/button.stories.tsx @@ -0,0 +1,134 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Check, Download, Trash2 } from "lucide-react"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { Button } from "../../src/index.ts"; + +const VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"] as const; +const SIZES = ["default", "xs", "sm", "lg", "icon", "icon-xs", "icon-sm", "icon-lg"] as const; + +const meta: Meta = { + title: "Primitives/Button", + component: Button, + args: { children: "Save changes", onClick: fn() }, + argTypes: { + variant: { control: "select", options: VARIANTS }, + size: { control: "select", options: SIZES }, + disabled: { control: "boolean" }, + asChild: { table: { disable: true } }, + }, + parameters: { + docs: { + description: { + component: + "The base button. `variant` and `size` come from a `cva` recipe, and both are mirrored " + + "onto `data-variant` / `data-size` so a consuming app can target them from CSS.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement, args }) => { + const button = within(canvasElement).getByRole("button", { name: "Save changes" }); + await expect(button).toHaveAttribute("data-variant", "default"); + await userEvent.click(button); + await expect(args.onClick).toHaveBeenCalledOnce(); + }, +}; + +export const Variants: Story = { + render: (args) => ( +
+ {VARIANTS.map((variant) => ( + + ))} +
+ ), + parameters: { controls: { exclude: ["variant", "children"] } }, +}; + +export const Sizes: Story = { + render: (args) => ( +
+ {SIZES.map((size) => ( + + ))} +
+ ), + parameters: { controls: { exclude: ["size", "children"] } }, +}; + +export const WithIcon: Story = { + args: { children: undefined }, + render: (args) => ( +
+ + + +
+ ), + parameters: { + docs: { + description: { + story: + "An svg child is sized to 1rem and the horizontal padding tightens automatically — the " + + "recipe does it with `has-[>svg]:px-3`, so no extra prop is needed.", + }, + }, + }, +}; + +export const Disabled: Story = { + args: { disabled: true }, + play: async ({ canvasElement, args }) => { + const button = within(canvasElement).getByRole("button", { name: "Save changes" }); + await expect(button).toBeDisabled(); + // The recipe also removes pointer events, so a click never reaches the + // handler in the first place. (That is why `TooltipIconButton` has to wrap a + // disabled trigger in a span to keep its tooltip working.) + await expect(getComputedStyle(button).pointerEvents).toBe("none"); + await expect(args.onClick).not.toHaveBeenCalled(); + }, +}; + +export const AsChild: Story = { + args: { asChild: true, children: undefined }, + render: (args) => ( + + ), + parameters: { + docs: { + description: { + story: + "`asChild` renders the styling onto the child element instead of a ` + + + + ), + parameters: { + docs: { + description: { + story: + "Adding `border-b` to the header or `border-t` to the footer also adds the padding that " + + "goes with a divider — the recipe keys the spacing off the border class itself.", + }, + }, + }, +}; + +export const Grid: Story = { + render: (args) => ( +
+ {["Open Elements GmbH", "Nordwind AG", "Baltic Systems"].map((name) => ( + + + {name} + Customer + + +

Last contact 12 days ago

+
+
+ ))} +
+ ), +}; diff --git a/stories/primitives/input.stories.tsx b/stories/primitives/input.stories.tsx new file mode 100644 index 0000000..6eae77e --- /dev/null +++ b/stories/primitives/input.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { Input, Label } from "../../src/index.ts"; + +const meta: Meta = { + title: "Primitives/Input", + component: Input, + args: { placeholder: "anna.weber@example.com" }, + argTypes: { + type: { control: "select", options: ["text", "email", "password", "number", "search", "file"] }, + disabled: { control: "boolean" }, + "aria-invalid": { control: "boolean", name: "aria-invalid" }, + }, + render: (args) => , + parameters: { + docs: { + description: { + component: + "A single-line field. The error styling is driven entirely by `aria-invalid`, so the " + + "accessible state and the visual state cannot drift apart.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const input = within(canvasElement).getByRole("textbox"); + await userEvent.type(input, "hello@example.com"); + await expect(input).toHaveValue("hello@example.com"); + }, +}; + +export const WithLabel: Story = { + render: (args) => ( +
+ + +
+ ), + play: async ({ canvasElement }) => { + // The label is wired to the field, so the field is reachable by its name. + await expect(within(canvasElement).getByLabelText("Email address")).toBeVisible(); + }, +}; + +export const Invalid: Story = { + args: { "aria-invalid": true, defaultValue: "not-an-email" }, + render: (args) => ( +
+ + +

+ Enter a valid email address. +

+
+ ), +}; + +export const Disabled: Story = { + args: { disabled: true, defaultValue: "Read only for now" }, +}; + +export const Types: Story = { + render: (args) => ( +
+ {(["text", "email", "password", "number", "search"] as const).map((type) => ( +
+ + +
+ ))} +
+ ), + parameters: { controls: { exclude: ["type", "placeholder"] } }, +}; diff --git a/stories/primitives/label.stories.tsx b/stories/primitives/label.stories.tsx new file mode 100644 index 0000000..65c2069 --- /dev/null +++ b/stories/primitives/label.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CircleAlert } from "lucide-react"; +import { expect, within } from "storybook/test"; +import { Input, Label } from "../../src/index.ts"; + +const meta: Meta = { + title: "Primitives/Label", + component: Label, + args: { children: "Company name" }, + parameters: { + docs: { + description: { + component: + "A form label. It dims itself when the field it names is disabled — via `peer-disabled:` " + + "for a sibling control and `group-data-[disabled=true]:` inside a disabled group — so the " + + "label never looks active next to a dead field.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( +
+
+ ), + play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByLabelText("Company name")).toBeVisible(); + }, +}; + +export const WithIcon: Story = { + args: { children: undefined }, + render: (args) => ( + + ), +}; + +export const NextToADisabledField: Story = { + render: (args) => ( +
+ {/* `peer` on the control is what lets the label react to its disabled state. */} + +
+ ), + parameters: { + docs: { + description: { + story: + "The dimming rule is `peer-disabled:`, so it only fires when the label follows the " + + "control in the DOM. Here the visual order is restored with `order-*`.", + }, + }, + }, +}; diff --git a/stories/primitives/separator.stories.tsx b/stories/primitives/separator.stories.tsx new file mode 100644 index 0000000..22bb7b1 --- /dev/null +++ b/stories/primitives/separator.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { Separator } from "../../src/index.ts"; + +const meta: Meta = { + title: "Primitives/Separator", + component: Separator, + argTypes: { + orientation: { control: "inline-radio", options: ["horizontal", "vertical"] }, + decorative: { + control: "boolean", + description: + "Decorative separators are hidden from assistive technology. Turn it off when the rule " + + "genuinely separates two regions.", + }, + }, + parameters: { + docs: { + description: { + component: "A one-pixel rule. Its thickness swaps axis with `orientation`.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Horizontal: Story = { + args: { orientation: "horizontal" }, + render: (args) => ( +
+

Contact details

+ +

anna.weber@example.com

+
+ ), + play: async ({ canvasElement }) => { + const rule = canvasElement.querySelector('[data-slot="separator"]'); + await expect(rule).not.toBeNull(); + await expect(getComputedStyle(rule!).height).toBe("1px"); + }, +}; + +export const Vertical: Story = { + args: { orientation: "vertical" }, + render: (args) => ( +
+ Overview + + Contacts + + Tasks +
+ ), + play: async ({ canvasElement }) => { + const rule = canvasElement.querySelector('[data-slot="separator"]'); + await expect(getComputedStyle(rule!).width).toBe("1px"); + }, +}; + +export const Semantic: Story = { + args: { decorative: false }, + render: (args) => ( +
+

Section one

+ +

Section two

+
+ ), + play: async ({ canvasElement }) => { + // Not decorative, so it is exposed as a separator rather than hidden. + await expect(canvasElement.querySelector('[role="separator"]')).not.toBeNull(); + }, +}; diff --git a/stories/primitives/skeleton.stories.tsx b/stories/primitives/skeleton.stories.tsx new file mode 100644 index 0000000..3ca146c --- /dev/null +++ b/stories/primitives/skeleton.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { Card, CardContent, CardHeader, Skeleton } from "../../src/index.ts"; + +const meta: Meta = { + title: "Primitives/Skeleton", + component: Skeleton, + parameters: { + docs: { + description: { + component: + "A pulsing placeholder. It carries no size of its own — the shape of what is loading is " + + "expressed with utility classes at the call site.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , + play: async ({ canvasElement }) => { + const skeleton = canvasElement.querySelector('[data-slot="skeleton"]'); + await expect(skeleton).not.toBeNull(); + // `animate-pulse` is a Tailwind core utility, so it must have resolved. + await expect(getComputedStyle(skeleton!).animationName).not.toBe("none"); + }, +}; + +export const TextBlock: Story = { + render: (args) => ( +
+ + + +
+ ), +}; + +export const LoadingCard: Story = { + render: (args) => ( + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: "The placeholder mirrors the real card's layout, so nothing jumps when data lands.", + }, + }, + }, +}; + +export const Avatar: Story = { + render: (args) => ( +
+ +
+ + +
+
+ ), +}; diff --git a/stories/primitives/textarea.stories.tsx b/stories/primitives/textarea.stories.tsx new file mode 100644 index 0000000..9bfb439 --- /dev/null +++ b/stories/primitives/textarea.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { Label, Textarea } from "../../src/index.ts"; + +const meta: Meta = { + title: "Primitives/Textarea", + component: Textarea, + args: { placeholder: "Add a note…" }, + argTypes: { + disabled: { control: "boolean" }, + rows: { control: { type: "number", min: 1, max: 20 } }, + "aria-invalid": { control: "boolean", name: "aria-invalid" }, + }, + render: (args) =>