diff --git a/.gitignore b/.gitignore index 7ca0063..d5d6c17 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ node_modules/ dist/ examples/catalog/dist/ *.tmp.md + +# The Claude Design sync: its inputs, its staged converter and the bundle it +# produces. Local tooling, not part of the package. +.design-sync/ +.ds-sync/ +ds-bundle/ diff --git a/README.md b/README.md index 27cf136..3833eef 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ # @robomous/ui-core -The Robomous design system, built on top of [shadcn/ui](https://ui.shadcn.com): twenty-two React -components this package owns outright, over Radix UI and Base UI behaviour, and the one stylesheet -they resolve through. Extracted from [Robomous/VisionSet](https://github.com/Robomous/VisionSet), -where it grew up. +The Robomous design system, built on top of [shadcn/ui](https://ui.shadcn.com): forty React +components this package owns outright, over Radix UI and Base UI behaviour — plus cmdk under +`Command` and vaul under `Drawer` — and the one stylesheet they resolve through. Extracted from +[Robomous/VisionSet](https://github.com/Robomous/VisionSet), where it grew up. ## Install diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 66d3955..b04727b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -35,13 +35,27 @@ pnpm dlx shadcn@latest add `components.json` tells the CLI to write into `src/components/` and `src/hooks/` with `@/` imports, which the build resolves. Read the diff, then adapt: the checklist is in [DESIGN.md](DESIGN.md), *Adding a component*. In short: colour only through the roles, geometry in -the component, `type="button"`, no exit animation on a menu, export by name from `src/index.ts`, -a behaviour test in `tests/components/`, a specimen in the catalog, a row in -`docs/components/README.md`. Never reinstall an existing component. +the component, `type="button"`, no exit animation on a surface whose trigger can be pressed again on +the next frame, export by name from `src/index.ts`, a behaviour test in `tests/components/`, a +specimen in the catalog, a row in `docs/components/README.md`. Never reinstall an existing +component. -State styles may use either the attribute the behaviour library emits (`data-[state=open]:`, -`data-open:`) or shadcn's variants, which the vendored layer defines; see DESIGN.md, *State -attributes*. +The CLI pulls in an item's registry dependencies, which for most of the newer components include +`button`, `input`, `dialog` and `input-group` — all of them already here and edited. It asks about +each one, and the prompt is interactive: under `CI=1` or a non-terminal stdin it hangs and the +batch stops half-written. Answer it from stdin and the run finishes: + +``` +yes n | pnpm dlx shadcn@latest add --yes +``` + +Then check that the files it offered to overwrite are unchanged before reading the diff. + +State styles are written in shadcn's variants — `data-open:`, `data-closed:`, `data-active:`, +`data-disabled:`, `data-horizontal:`, `data-vertical:` — never in the attribute a behaviour library +happens to emit (`data-[state=open]:`, `data-[orientation=vertical]:`). A `data-[…]` bracket is for +the values the layer declares no variant for, and for attributes that are not states; see +DESIGN.md, *State attributes*. ## Updating the shadcn layer diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6bef95b..2424d60 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,15 +2,16 @@ ## Purpose and ownership -`@robomous/ui-core` is twenty-two React components this repository owns outright, over behaviour -from Radix UI and Base UI, and the one stylesheet they resolve through. The package is **built on -top of shadcn/ui**: a component starts as an item of the shadcn registry, installed through -`components.json`, and from that moment it is ordinary source here — edited with a reason, a test -and a review like any other file, never regenerated and never compared back to upstream. It is not -an extension of shadcn. shadcn supplies the starting point and the utility and variant layer the -stylesheet vendors; Radix and Base UI supply focus management, keyboard interaction, dismissal, -`aria-*` relationships and the `data-*` state attributes; Robomous owns the API, the styling, the -semantic variants, the geometry and the public contract. +`@robomous/ui-core` is forty React components this repository owns outright, over behaviour from +Radix UI and Base UI — plus cmdk under `Command` and vaul under `Drawer` — and the one stylesheet +they resolve through. The package is **built on top of shadcn/ui**: a component starts as an item +of the shadcn registry, installed through `components.json`, and from that moment it is ordinary +source here — edited with a reason, a test and a review like any other file, never regenerated and +never compared back to upstream. It is not an extension of shadcn. shadcn supplies the starting +point and the utility and variant layer the stylesheet vendors; Radix, Base UI, cmdk and vaul +supply focus management, keyboard interaction, dismissal, `aria-*` relationships and the `data-*` +state attributes; Robomous owns the API, the styling, the semantic variants, the geometry and the +public contract. The package is product-agnostic. Shells, navigation, domain cards, model selectors, user menus, billing screens and the like stay in the product that needs them until the *same composition* is @@ -176,9 +177,7 @@ from Tailwind's scale; there is no custom type-scale token. Radix sets `data-state="open|closed|active|…"` and `data-orientation="horizontal|vertical"`. Base UI sets bare `data-open`, `data-closed`, `data-highlighted`, `data-empty`. Both set a bare -`data-disabled`. A component written before the shadcn layer returned spells the attribute its -library emits — `data-[state=open]:` for Radix, `data-open:` for Base UI — and those components are -not rewritten. +`data-disabled`. Two libraries, two spellings for one state — and a component here writes neither. **shadcn's variant layer sits under all of it**, vendored in `src/theme/shadcn.css`. It declares `data-open`, `data-closed`, `data-checked`, `data-unchecked`, `data-selected`, `data-disabled`, @@ -186,14 +185,27 @@ not rewritten. `[data-state="open"]` and a bare `[data-open]` — and excludes an explicit `"false"`. That last clause is why the layer is load-bearing rather than convenient: `SidebarMenuButton` renders `data-active="false"` for an inactive item, Tailwind's built-in `data-active:` variant matches on -presence alone, and without the layer every item would be styled active. A component installed -from the registry uses shadcn's spelling as written; an older component's explicit spelling keeps -working under the layer unchanged. Both are correct here. +presence alone, and without the layer every item would be styled active. + +**Those variants are the spelling.** `data-open:`, never `data-[state=open]:`; `data-horizontal:`, +never `data-[orientation=horizontal]:`; `group-data-disabled/field:`, never +`group-data-[disabled=true]/field:`. One name covers the Radix surface and the Base UI one, so +Dialog and Combobox read alike and a component that changes libraries changes no class. A bracket +survives in exactly two places: a state the layer declares no variant for — Tooltip's +`data-[state=delayed-open]:`, Table's `data-[state=selected]:`, Sidebar's `data-[state=collapsed]:` +— and an attribute that is not a state at all, `data-[slot=…]`, `data-[variant=…]`, `data-[size=…]`, +`data-[side=…]`, `data-[collapsible=…]`. + +The breadth is bought with specificity. `:where()` carries none, so `data-open:bg-accent` weighs a +single class where `data-[state=open]:bg-accent` weighed a class and an attribute. It still wins +over an unqualified utility, which Tailwind emits earlier in the layer, and it still loses to a +`group-`/`peer-` qualified rule. What changes is that `hover:`, `focus-visible:` and `disabled:` +now win on a property they share with it — which is the order a control wants: the pointer and the +focus ring are about the here and now, and the state is the background they play against. `shadcn.css` is never edited. Anything of ours — `cn-rtl-flip`, which the registry's components name and shadcn defines nowhere — is declared in `styles.css` after the import. `tests/theme/shadcn.test.ts` holds the copy identical to the installed package. - ## Motion An **enter** animation is free to play: the surface it introduces did not exist a frame ago. An @@ -201,12 +213,17 @@ An **enter** animation is free to play: the surface it introduces did not exist keeps the closed surface mounted and its dismissable layer with it, so a press meant to open the next menu is read as an interaction outside the closing one and swallowed. -**A menu leaves on the frame it is dismissed — `DropdownMenuContent` and `DropdownMenuSubContent` -alike.** Neither carries an exit animation. `tests/components/dropdown-menu.test.tsx` gives any -exit-animation utility a real `animation-name` and a live `getComputedStyle`, so a surface that -lingers fails the way it does in a browser: the next press does not land. A surface whose trigger -cannot be pressed again straight away — a dialog's, a sheet's, a tooltip's — keeps its exit -animation. `TooltipProvider` defaults `delayDuration` to `0`. +**A surface whose trigger can be pressed again on the next frame leaves on the frame it is +dismissed.** That is `DropdownMenuContent` and `DropdownMenuSubContent`, `ContextMenuContent` and +`ContextMenuSubContent`, and `PopoverContent`: none of them carries an exit animation. The test for +each one gives any exit-animation utility a real `animation-name` and a live `getComputedStyle`, so +a surface that lingers fails the way it does in a browser — the next press does not land — and each +file first proves the fixture bites on that component before reading its absence as a pass. + +A surface whose trigger *cannot* be pressed again straight away keeps its exit animation: a +dialog's, a sheet's, a drawer's, a tooltip's, a hover card's. The reader has to move a pointer or +find the trigger again, and the frames the animation costs are frames nobody was waiting on. +`TooltipProvider` defaults `delayDuration` to `0`. `prefers-reduced-motion` sits above all of this: the base layer collapses every animation and transition to a single frame under that query, so no component opts in. @@ -292,7 +309,7 @@ justification written into this file. | No literal colour in a class | `eslint.config.js`, `pnpm lint` | | Roles agree between the stylesheet and its mirror; the palette is closed; no `:focus-visible` rule | `tests/theme/tokens.test.ts` | | The vendored shadcn layer is identical to the installed package, committed, imported, and a dev dependency only | `tests/theme/shadcn.test.ts` | -| Button type, Dialog/Sheet focus and dismissal, Field's explicit contract, menu dismissal, Tabs, Select, Progress, Combobox, Toaster theme, Sidebar toggling and its mobile Sheet | `tests/components/*.test.tsx` | +| Button type, Dialog/Sheet/Drawer focus and dismissal, Field's explicit contract, menu and popover dismissal, Tabs, Select, Progress, Combobox, Command, RadioGroup and ToggleGroup selection, Breadcrumb and Pagination landmarks, Toaster theme, Sidebar toggling and its mobile Sheet | `tests/components/*.test.tsx` | | The packed tarball installs, its stylesheet compiles under a real Tailwind with the components' utilities, shadcn's layer and no physical palette, its entry imports and renders (Sidebar included, through the rewritten alias), `dist/` carries no `@/` import, `shadcn` is not a runtime dependency, a Button-only bundle stays small | `tests/package/consumer.test.ts` | | The harness itself | `tests/harness.test.tsx` | diff --git a/docs/components/README.md b/docs/components/README.md index 8a84ce4..f3636d7 100644 --- a/docs/components/README.md +++ b/docs/components/README.md @@ -1,6 +1,6 @@ # Components -Twenty-two components, one file each in `src/components/`, exported by name from `src/index.ts`. +Forty components, one file each in `src/components/`, exported by name from `src/index.ts`. The package is built on top of shadcn/ui: a component enters from the shadcn registry through `components.json` (`pnpm dlx shadcn@latest add `), is adapted to the rules in [DESIGN.md](../DESIGN.md), and is owned here from then on. @@ -11,26 +11,44 @@ is for and where its behaviour comes from. Every one of them is shown in every s | Component | Anatomy exported | Behaviour | Notes | | --- | --- | --- | --- | | Alert | `Alert`, `AlertTitle`, `AlertDescription`, `AlertAction` | none (`role="alert"`) | `default`, `destructive`. Ink recolours; the border does not. | +| Attachment | `Attachment`, `AttachmentGroup`, `AttachmentMedia`, `AttachmentContent`, `AttachmentTitle`, `AttachmentDescription`, `AttachmentActions`, `AttachmentAction`, `AttachmentTrigger` | Radix Slot (`asChild` on the trigger) | An uploaded file as a card. `state`: `idle`, `uploading`, `processing`, `error`, `done` — every part styles off it. `size`, `orientation`. `AttachmentTrigger` covers the card and renders `type="button"`. | +| Avatar | `Avatar`, `AvatarImage`, `AvatarFallback`, `AvatarBadge`, `AvatarGroup`, `AvatarGroupCount` | Radix Avatar | `size`: `sm`, `default`, `lg`, carried as `data-size` so a group sizes its overflow count to match. A fallback is not optional. | | Badge | `Badge`, `badgeVariants` | Radix Slot (`asChild`) | `default`, `secondary`, `destructive`, `success`, `warning`, `info`, `quiet`, `outline`, `ghost`, `link`. | +| Breadcrumb | `Breadcrumb`, `BreadcrumbList`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator`, `BreadcrumbEllipsis` | Radix Slot (`asChild` on the link) | `nav[aria-label="breadcrumb"]`. The last crumb is a `BreadcrumbPage` (`aria-current="page"`), not a link; separators are `aria-hidden`. | | Button | `Button`, `buttonVariants` | Radix Slot (`asChild`) | `type="button"` by default. Six variants; sizes incl. `icon-*` and `inline`. | +| ButtonGroup | `ButtonGroup`, `ButtonGroupText`, `ButtonGroupSeparator`, `buttonGroupVariants` | Radix Slot (`asChild` on the text segment) | `role="group"`; `orientation`. Rounds the first and last child that carry a `data-slot`, so every segment has one. | | Card | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter` | none | `size="sm"` tightens `--card-spacing`. | | Combobox | `Combobox`, `ComboboxInput`, `ComboboxContent`, `ComboboxList`, `ComboboxItem`, `ComboboxGroup`, `ComboboxLabel`, `ComboboxCollection`, `ComboboxEmpty`, `ComboboxSeparator`, `ComboboxChips`, `ComboboxChip`, `ComboboxChipsInput`, `ComboboxTrigger`, `ComboboxValue`, `useComboboxAnchor` | Base UI Combobox | Bare `data-open` / `data-highlighted` state attributes. | +| Command | `Command`, `CommandDialog`, `CommandInput`, `CommandList`, `CommandEmpty`, `CommandGroup`, `CommandItem`, `CommandSeparator`, `CommandShortcut` | cmdk; `CommandDialog` adds Radix Dialog | A listbox driven from a field the reader never leaves. `CommandDialog`'s title and description are `sr-only` *inside* the dialog, which is what names it. | +| ContextMenu | `ContextMenu`, `ContextMenuTrigger`, `ContextMenuPortal`, `ContextMenuContent`, `ContextMenuGroup`, `ContextMenuLabel`, `ContextMenuItem`, `ContextMenuCheckboxItem`, `ContextMenuRadioGroup`, `ContextMenuRadioItem`, `ContextMenuSeparator`, `ContextMenuShortcut`, `ContextMenuSub`, `ContextMenuSubTrigger`, `ContextMenuSubContent` | Radix ContextMenu | No exit animation on either surface, for DropdownMenu's reason. `variant="destructive"` and `inset` on an item. | | Dialog | `Dialog`, `DialogTrigger`, `DialogPortal`, `DialogOverlay`, `DialogContent`, `DialogHeader`, `DialogFooter`, `DialogTitle`, `DialogDescription`, `DialogClose` | Radix Dialog | `showCloseButton` on Content and Footer. Overlay paints `bg-overlay`. Keeps its exit animation. | +| Drawer | `Drawer`, `DrawerTrigger`, `DrawerPortal`, `DrawerOverlay`, `DrawerContent`, `DrawerHeader`, `DrawerFooter`, `DrawerTitle`, `DrawerDescription`, `DrawerClose` | vaul | The one component whose behaviour is neither Radix nor Base UI. `direction`; a drag handle on the bottom drawer. Overlay paints `bg-overlay`. Keeps its exit animation, as a Dialog does. | | DropdownMenu | `DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuPortal`, `DropdownMenuContent`, `DropdownMenuGroup`, `DropdownMenuLabel`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioGroup`, `DropdownMenuRadioItem`, `DropdownMenuSeparator`, `DropdownMenuShortcut`, `DropdownMenuSub`, `DropdownMenuSubTrigger`, `DropdownMenuSubContent` | Radix DropdownMenu | Sizes to its items (`min-w-32` floor). No exit animation on either surface. `variant="destructive"` on an item. | +| Empty | `Empty`, `EmptyHeader`, `EmptyMedia`, `EmptyTitle`, `EmptyDescription`, `EmptyContent` | none | The panel that says what is missing and what to do about it. `EmptyMedia` `variant`: `default`, `icon`. | | Field | `Field`, `FieldLabel`, `FieldDescription`, `FieldError`, `FieldGroup`, `FieldSet`, `FieldLegend`, `FieldContent`, `FieldTitle`, `FieldSeparator` | none (`Field` is `role="group"`, `FieldError` is `role="alert"`) | Anatomy only: `htmlFor`, `id`, `aria-describedby`, `aria-invalid` are the call site's. `orientation` and `data-invalid` on `Field`. | +| HoverCard | `HoverCard`, `HoverCardTrigger`, `HoverCardContent` | Radix HoverCard | Pointer-only preview, so it is never the sole route to anything. Keeps its exit animation: nothing is waiting on the frame after. | | Input | `Input` | native | `aria-invalid` styles the invalid state. | | InputGroup | `InputGroup`, `InputGroupAddon`, `InputGroupButton`, `InputGroupInput`, `InputGroupText`, `InputGroupTextarea` | none | Addon `align`: `inline-start`, `inline-end`, `block-start`, `block-end`. | +| Item | `Item`, `ItemGroup`, `ItemSeparator`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions`, `ItemHeader`, `ItemFooter` | Radix Slot (`asChild`) | A row with a title, a description and its own actions. `variant`: `default`, `outline`, `muted`; `size`: `default`, `sm`, `xs`. `ItemGroup` is `role="list"`. | +| Kbd | `Kbd`, `KbdGroup` | none | A real ``. `KbdGroup` holds a chord. | | Label | `Label` | Radix Label | | +| Pagination | `Pagination`, `PaginationContent`, `PaginationItem`, `PaginationLink`, `PaginationPrevious`, `PaginationNext`, `PaginationEllipsis` | Button (Radix Slot) under each link | `nav[aria-label="pagination"]`; every step is a real anchor. `isActive` renders `aria-current="page"` and `data-active`, styled through shadcn's variant layer. | +| Popover | `Popover`, `PopoverTrigger`, `PopoverAnchor`, `PopoverContent`, `PopoverHeader`, `PopoverTitle`, `PopoverDescription` | Radix Popover | No exit animation: its trigger is a button the reader can press again on the next frame. | | Progress | `Progress` | Radix Progress | Forwards `value`; `aria-valuenow` announced. No variant. | +| RadioGroup | `RadioGroup`, `RadioGroupItem` | Radix RadioGroup | One tab stop; the arrows move the selection, not just the focus. `aria-invalid` styles the invalid state. | +| ScrollArea | `ScrollArea`, `ScrollBar` | Radix ScrollArea | The viewport is the scroll container, not the root. `ScrollBar` `orientation`. | | Select | `Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectGroup`, `SelectLabel`, `SelectItem`, `SelectSeparator`, `SelectScrollUpButton`, `SelectScrollDownButton` | Radix Select | `SelectTrigger` `size` (`sm`, `default`) and `multiline`. | | Separator | `Separator` | Radix Separator | `orientation`, `decorative`. | | Sheet | `Sheet`, `SheetTrigger`, `SheetClose`, `SheetContent`, `SheetHeader`, `SheetFooter`, `SheetTitle`, `SheetDescription` | Radix Dialog | `side`: `top`, `right`, `bottom`, `left`. | | Sidebar | `SidebarProvider`, `Sidebar`, `SidebarTrigger`, `SidebarRail`, `SidebarInset`, `SidebarInput`, `SidebarHeader`, `SidebarFooter`, `SidebarSeparator`, `SidebarContent`, `SidebarGroup`, `SidebarGroupLabel`, `SidebarGroupAction`, `SidebarGroupContent`, `SidebarMenu`, `SidebarMenuItem`, `SidebarMenuButton`, `SidebarMenuAction`, `SidebarMenuBadge`, `SidebarMenuSkeleton`, `SidebarMenuSub`, `SidebarMenuSubItem`, `SidebarMenuSubButton`, `useSidebar` | Radix Slot (`asChild`); a Sheet (Radix Dialog) under 768px; Tooltip on a collapsed `SidebarMenuButton` | `side`, `variant` (`sidebar`, `floating`, `inset`), `collapsible` (`offcanvas`, `icon`, `none`). `⌘/Ctrl+B` toggles; state persists in the `sidebar_state` cookie. `isActive` renders `data-active`, styled through shadcn's variant layer. | | Skeleton | `Skeleton` | none | | | Sonner | `Toaster`, `toast` | sonner | `Toaster` follows the `dark` class on ``; icons from lucide. | +| Spinner | `Spinner` | none (`role="status"`) | A lucide glyph that announces itself as `Loading`. Compose it into a Button with `data-icon` and `disabled`; there is no `isPending` prop. | | Table | `Table`, `TableHeader`, `TableBody`, `TableFooter`, `TableRow`, `TableHead`, `TableCell`, `TableCaption` | native | `Table` wraps itself in an `overflow-x-auto` container. | | Tabs | `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`, `tabsListVariants` | Radix Tabs | `TabsList` `variant`: `default`, `line`. Horizontal and vertical. | | Textarea | `Textarea` | native | `field-sizing-content`. | +| Toggle | `Toggle`, `toggleVariants` | Radix Toggle | `aria-pressed` is the state. `variant`: `default`, `outline`; `size`: `sm`, `default`, `lg`. | +| ToggleGroup | `ToggleGroup`, `ToggleGroupItem` | Radix ToggleGroup | `type`: `single` (radios) or `multiple` (pressed buttons). `spacing={0}` welds the segments into one control; `orientation`. Items inherit the group's `variant` and `size`. | | Tooltip | `TooltipProvider`, `Tooltip`, `TooltipTrigger`, `TooltipContent` | Radix Tooltip | `delayDuration` defaults to `0`. Painted `bg-foreground text-background`. | Also exported: `cn`, the `useIsMobile` hook the Sidebar decides its mode with, and the token mirror diff --git a/eslint.config.js b/eslint.config.js index 8cec579..f9b2e82 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,6 @@ +import path from "node:path"; + +import { includeIgnoreFile } from "@eslint/compat"; import js from "@eslint/js"; import reactHooks from "eslint-plugin-react-hooks"; import tseslint from "typescript-eslint"; @@ -16,7 +19,10 @@ const COLOUR_MESSAGE = "utility (bg-primary, text-warning), never a literal inside a class."; export default tseslint.config( - { ignores: ["dist/", "examples/catalog/dist/"] }, + // Whatever the repository already ignores, the linter ignores: build output and + // local tooling are not ours to hold to these rules, and naming them twice is how + // the two lists drift. `.gitignore` is the one list. + includeIgnoreFile(path.resolve(import.meta.dirname, ".gitignore")), js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/examples/catalog/src/sections/Components.tsx b/examples/catalog/src/sections/Components.tsx index 7eef872..a50b23d 100644 --- a/examples/catalog/src/sections/Components.tsx +++ b/examples/catalog/src/sections/Components.tsx @@ -1,9 +1,33 @@ +import { useState } from "react"; import { Alert, AlertDescription, AlertTitle, + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, + Avatar, + AvatarBadge, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, Badge, + Breadcrumb, + BreadcrumbEllipsis, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, Button, + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, Card, CardContent, CardDescription, @@ -15,6 +39,29 @@ import { ComboboxInput, ComboboxItem, ComboboxList, + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, Dialog, DialogContent, DialogDescription, @@ -22,6 +69,14 @@ import { DialogHeader, DialogTitle, DialogTrigger, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -31,12 +86,48 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, Field, FieldDescription, FieldError, FieldLabel, + FieldLegend, + FieldSet, + HoverCard, + HoverCardContent, + HoverCardTrigger, Input, + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemGroup, + ItemMedia, + ItemTitle, + Kbd, + KbdGroup, + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, Progress, + RadioGroup, + RadioGroupItem, + ScrollArea, Select, SelectContent, SelectItem, @@ -70,6 +161,7 @@ import { SidebarProvider, SidebarSeparator, Skeleton, + Spinner, Table, TableBody, TableCell, @@ -82,18 +174,33 @@ import { TabsTrigger, Textarea, toast, + Toggle, + ToggleGroup, + ToggleGroupItem, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@robomous/ui-core"; import { + AlignCenterIcon, + AlignLeftIcon, + AlignRightIcon, + BoldIcon, + ChevronDownIcon, + CopyIcon, + FileIcon, FolderIcon, + ImageIcon, InboxIcon, + ItalicIcon, MoreHorizontalIcon, PlusIcon, SettingsIcon, + TrashIcon, + UnderlineIcon, UserIcon, + XIcon, } from "lucide-react"; import { Section, Specimen } from "../App"; @@ -117,7 +224,7 @@ export function Components() {
+ + + + YA + + + YA + + + YA + + + + + A + + + B + + + C + + +3 + + + + + + ⌘ + K + + Esc + + + + + + +
+
+ + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ Visibility + + + + Draft + + + + Internal + + + + Public + + +
+
+ + +
+ + https:// + + + + + + + + + + + + + +
+
+ + +
+ + + + Datasets + + + + Warehouse + + + + + + + + Batch 12 + + + + + + + + + + + 1 + + + + + 2 + + + + + + + + 9 + + + + + + + +
+
+ + +
+ + + + + + + batch-12.zip + 311.9 MB, uploaded today + + + + + + + + + + + + archive/ + 48 batches + + + + + + + + + No batches yet + + Upload a batch to start labelling. Nothing is lost while you wait. + + + + + + +
+
+ + + + + + + + + batch-12.zip + 311.9 MB + + + + + + + + + + + + + frames-03.tar + 1.2 GB + + + + + + + + broken.zip + Upload failed + + + + + + + + Drop an image + + + + + + + + + + + + + Rename + Shown wherever this batch is listed. + + + + + + + + + + +
+ + RO + +
+ Robomous + Vision tooling. Joined 2026. +
+
+
+
+
+ + + + + Right-click here + + + batch-12.zip + + + + + Copy link + ⌘C + + Show hidden frames + + Move to + + + Archive + Review + + + + + + + + Delete + + + + + + + + + + + + + Filters + Narrow the batches shown. + +
+ + Name contains + + +
+ + + + + + +
+
+
+ + + + + + Nothing matches. + + + + Upload a batch + ⌘U + + + + Import from URL + + + + + + + Edit classes + + + + + + + + + +
+ {Array.from({ length: 24 }, (_, index) => ( +
+ frame_{String(index).padStart(4, "0")}.png +
+ ))} +
+
+
); } + +/** + * The palette in its dialog. Open state is the only reason this is a component + * of its own rather than more JSX in the specimen above. + */ +function CommandPaletteDemo() { + const [open, setOpen] = useState(false); + return ( + <> + + + + + + Nothing matches. + + setOpen(false)}> + + Upload a batch + + setOpen(false)}> + + Import from URL + + + + + + + ); +} diff --git a/package.json b/package.json index c8c638a..45581bf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@robomous/ui-core", "version": "0.4.0", - "description": "Robomous design system, built on top of shadcn/ui: owned React components over Radix and Base UI, and the one stylesheet they resolve through.", + "description": "Robomous design system, built on top of shadcn/ui: owned React components over Radix, Base UI, cmdk and vaul, and the one stylesheet they resolve through.", "license": "Apache-2.0", "repository": { "type": "git", @@ -41,11 +41,13 @@ "@base-ui/react": "^1.7.0", "@fontsource-variable/geist": "^5.3.0", "class-variance-authority": "^0.7.1", + "cmdk": "^1.1.1", "cn": "^0.2.4", "lucide-react": "^1.32.0", "radix-ui": "^1.6.7", "sonner": "^2.0.8", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2" }, "peerDependencies": { "react": ">=19", @@ -53,6 +55,7 @@ "tailwindcss": ">=4" }, "devDependencies": { + "@eslint/compat": "^2.1.1", "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16a12a8..376fd64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) cn: specifier: ^0.2.4 version: 0.2.5 @@ -133,7 +136,13 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: + '@eslint/compat': + specifier: ^2.1.1 + version: 2.1.1(eslint@10.9.1(jiti@2.7.0)) '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.9.1(jiti@2.7.0)) @@ -221,7 +230,7 @@ importers: version: 6.1.1(vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0)) lucide-react: specifier: ^1.37.0 - version: 1.37.0 + version: 1.37.0(react@19.2.8) tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -459,6 +468,15 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint/compat@2.1.1': + resolution: {integrity: sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^8.40 || 9 || 10 + peerDependenciesMeta: + eslint: + optional: true + '@eslint/config-array@0.23.5': resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1804,6 +1822,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + cn@0.2.5: resolution: {integrity: sha512-OCjZtMeQfXbI4Es1+EIjkd77gvWzaE689gD8KhfexlqjClC06qR1MQBR+Z35ZMSPNEBWyHiItW1Soy0UvwNv9w==} engines: {node: '>=20'} @@ -3371,6 +3395,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vite@8.2.2: resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3824,6 +3854,12 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} + '@eslint/compat@2.1.1(eslint@10.9.1(jiti@2.7.0))': + dependencies: + '@eslint/core': 1.2.1 + optionalDependencies: + eslint: 10.9.1(jiti@2.7.0) + '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 @@ -5166,6 +5202,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + cn@0.2.5: {} code-block-writer@13.0.3: {} @@ -6674,6 +6722,15 @@ snapshots: vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vite@8.2.2(@types/node@26.4.0)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 diff --git a/src/components/attachment.tsx b/src/components/attachment.tsx new file mode 100644 index 0000000..1347904 --- /dev/null +++ b/src/components/attachment.tsx @@ -0,0 +1,192 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; +import { Slot } from "radix-ui"; + +import { Button } from "@/components/button"; + +const attachmentVariants = cva( + "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed", + { + variants: { + size: { + default: + "gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2", + sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5", + xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1", + }, + orientation: { + horizontal: "min-w-40 items-center", + vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30", + }, + }, + }, +); + +function Attachment({ + className, + state = "done", + size = "default", + orientation = "horizontal", + ...props +}: React.ComponentProps<"div"> & + VariantProps & { + state?: "idle" | "uploading" | "processing" | "error" | "done"; + }) { + return ( +
+ ); +} + +const attachmentMediaVariants = cva( + "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5", + { + variants: { + variant: { + icon: "", + image: + "opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover", + }, + }, + defaultVariants: { + variant: "icon", + }, + }, +); + +function AttachmentMedia({ + className, + variant = "icon", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AttachmentContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AttachmentTitle({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function AttachmentDescription({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function AttachmentActions({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AttachmentAction({ + className, + variant, + size = "icon-xs", + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function PaginationPrevious({ + className, + text = "Previous", + ...props +}: React.ComponentProps & { text?: string }) { + return ( + + + {text} + + ); +} + +function PaginationNext({ + className, + text = "Next", + ...props +}: React.ComponentProps & { text?: string }) { + return ( + + {text} + + + ); +} + +function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) { + return ( + + + More pages + + ); +} + +export { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +}; diff --git a/src/components/popover.tsx b/src/components/popover.tsx new file mode 100644 index 0000000..1f9ccbc --- /dev/null +++ b/src/components/popover.tsx @@ -0,0 +1,77 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { Popover as PopoverPrimitive } from "radix-ui"; + +function Popover({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverTrigger({ ...props }: React.ComponentProps) { + return ; +} + +// No exit animation, for the reason DropdownMenuContent has none: a popover's +// trigger is a button the reader can press again on the next frame, and while +// an exit animation runs Radix keeps the closed surface and its dismissable +// layer mounted, which swallows that press. See DESIGN.md, *Motion*. +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function PopoverAnchor({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function PopoverDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

+ ); +} + +export { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +}; diff --git a/src/components/radio-group.tsx b/src/components/radio-group.tsx new file mode 100644 index 0000000..ba1f8a1 --- /dev/null +++ b/src/components/radio-group.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import { cn } from "cn"; +import { RadioGroup as RadioGroupPrimitive } from "radix-ui"; + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/src/components/scroll-area.tsx b/src/components/scroll-area.tsx new file mode 100644 index 0000000..c88f0a5 --- /dev/null +++ b/src/components/scroll-area.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { cn } from "cn"; +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { ScrollArea, ScrollBar }; diff --git a/src/components/select.tsx b/src/components/select.tsx index a798e26..a56c400 100644 --- a/src/components/select.tsx +++ b/src/components/select.tsx @@ -32,7 +32,11 @@ function SelectTrigger({ ...props }: React.ComponentProps & { size?: "sm" | "default"; - /** Let the closed trigger grow to its value instead of clamping it to one line. */ + /** + * Let the closed trigger wrap and grow to its value instead of clamping it to one line. + * The trigger is `whitespace-nowrap` by default — lifting that is part of the prop's job, + * because `line-clamp-none` alone leaves a long value on one line, overflowing the box. + */ multiline?: boolean; }) { return ( @@ -40,9 +44,9 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-md *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-left text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-md *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", multiline && - "data-[size=default]:h-auto min-h-8 *:data-[slot=select-value]:line-clamp-none", + "data-[size=default]:h-auto min-h-8 whitespace-normal *:data-[slot=select-value]:line-clamp-none", className, )} {...props} @@ -68,7 +72,7 @@ function SelectContent({ data-slot="select-content" data-align-trigger={position === "item-aligned"} className={cn( - "relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", + "relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className, diff --git a/src/components/separator.tsx b/src/components/separator.tsx index b9229d6..5d1dce2 100644 --- a/src/components/separator.tsx +++ b/src/components/separator.tsx @@ -17,7 +17,7 @@ function Separator({ decorative={decorative} orientation={orientation} className={cn( - "shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch", + "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", className, )} {...props} diff --git a/src/components/sheet.tsx b/src/components/sheet.tsx index 2171456..97624f1 100644 --- a/src/components/sheet.tsx +++ b/src/components/sheet.tsx @@ -29,7 +29,7 @@ function SheetOverlay({ ) { + return ( + + ); +} + +export { Spinner }; diff --git a/src/components/tabs.tsx b/src/components/tabs.tsx index b299f7c..6f4730f 100644 --- a/src/components/tabs.tsx +++ b/src/components/tabs.tsx @@ -13,14 +13,14 @@ function Tabs({ ); } const tabsListVariants = cva( - "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none", + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", { variants: { variant: { @@ -54,10 +54,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps & { + spacing?: number; + orientation?: "horizontal" | "vertical"; + } +>({ + size: "default", + variant: "default", + spacing: 2, + orientation: "horizontal", +}); + +function ToggleGroup({ + className, + variant, + size, + spacing = 2, + orientation = "horizontal", + children, + ...props +}: React.ComponentProps & + VariantProps & { + spacing?: number; + orientation?: "horizontal" | "vertical"; + }) { + return ( + + + {children} + + + ); +} + +function ToggleGroupItem({ + className, + children, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & VariantProps) { + const context = React.useContext(ToggleGroupContext); + + return ( + + {children} + + ); +} + +export { ToggleGroup, ToggleGroupItem }; diff --git a/src/components/toggle.tsx b/src/components/toggle.tsx new file mode 100644 index 0000000..713e77b --- /dev/null +++ b/src/components/toggle.tsx @@ -0,0 +1,45 @@ +"use client"; + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; +import { Toggle as TogglePrimitive } from "radix-ui"; + +const toggleVariants = cva( + "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border border-input bg-transparent hover:bg-muted", + }, + size: { + default: + "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Toggle({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & VariantProps) { + return ( + + ); +} + +export { Toggle, toggleVariants }; diff --git a/src/components/tooltip.tsx b/src/components/tooltip.tsx index 7f8598a..29e5751 100644 --- a/src/components/tooltip.tsx +++ b/src/components/tooltip.tsx @@ -38,7 +38,7 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - "z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", + "z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className, )} {...props} diff --git a/src/index.ts b/src/index.ts index c69b366..54a414a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ /** * `@robomous/ui-core` — the Robomous design system, built on top of shadcn/ui. * - * Twenty-two React components this package owns outright, over Radix and Base - * UI behaviour, and the one stylesheet they resolve through. A consumer imports - * exactly: + * Forty React components this package owns outright, over Radix, Base UI, cmdk + * and vaul behaviour, and the one stylesheet they resolve through. A consumer + * imports exactly: * * ```ts * import "@robomous/ui-core/styles.css"; // once, in the app's entry @@ -20,8 +20,16 @@ export { cssVar, DARK_THEME, LIGHT_THEME, THEME } from "./theme/tokens.js"; export { cn } from "cn"; -// The components — Radix and Base UI behaviour, iconed with lucide. +// The components — Radix, Base UI, cmdk and vaul behaviour, iconed with lucide. export { Button, buttonVariants } from "./components/button.js"; +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} from "./components/button-group.js"; +export { Toggle, toggleVariants } from "./components/toggle.js"; +export { ToggleGroup, ToggleGroupItem } from "./components/toggle-group.js"; export { Card, CardAction, @@ -31,9 +39,41 @@ export { CardHeader, CardTitle, } from "./components/card.js"; +export { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemFooter, + ItemGroup, + ItemHeader, + ItemMedia, + ItemSeparator, + ItemTitle, +} from "./components/item.js"; +export { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "./components/empty.js"; +export { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, + AttachmentTrigger, +} from "./components/attachment.js"; export { Input } from "./components/input.js"; export { Textarea } from "./components/textarea.js"; export { Label } from "./components/label.js"; +export { RadioGroup, RadioGroupItem } from "./components/radio-group.js"; export { Field, FieldContent, @@ -72,7 +112,28 @@ export { ComboboxValue, useComboboxAnchor, } from "./components/combobox.js"; +export { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "./components/command.js"; export { Badge, badgeVariants } from "./components/badge.js"; +export { + Avatar, + AvatarBadge, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarImage, +} from "./components/avatar.js"; +export { Kbd, KbdGroup } from "./components/kbd.js"; +export { Spinner } from "./components/spinner.js"; export { Alert, AlertAction, AlertDescription, AlertTitle } from "./components/alert.js"; export { Dialog, @@ -96,7 +157,64 @@ export { SheetTitle, SheetTrigger, } from "./components/sheet.js"; +export { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + DrawerPortal, + DrawerTitle, + DrawerTrigger, +} from "./components/drawer.js"; +export { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "./components/popover.js"; +export { HoverCard, HoverCardContent, HoverCardTrigger } from "./components/hover-card.js"; +export { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuPortal, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "./components/context-menu.js"; export { Tabs, TabsContent, TabsList, tabsListVariants, TabsTrigger } from "./components/tabs.js"; +export { + Breadcrumb, + BreadcrumbEllipsis, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "./components/breadcrumb.js"; +export { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "./components/pagination.js"; export { Select, SelectContent, @@ -129,6 +247,7 @@ export { export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip.js"; export { Progress } from "./components/progress.js"; export { Separator } from "./components/separator.js"; +export { ScrollArea, ScrollBar } from "./components/scroll-area.js"; export { Skeleton } from "./components/skeleton.js"; export { Toaster } from "./components/sonner.js"; export { toast } from "sonner"; diff --git a/tests/components/command.test.tsx b/tests/components/command.test.tsx new file mode 100644 index 0000000..df4dd82 --- /dev/null +++ b/tests/components/command.test.tsx @@ -0,0 +1,143 @@ +/** + * Command, on the behaviour a screen would silently lose. + * + * A palette is a listbox a reader drives from a text field they never leave, so + * what is asserted is the wiring that makes that true: typing narrows the list, + * the arrow keys move a selection that focus does not follow, Enter runs the + * selected item, and an empty result says so out loud rather than showing a + * blank box. + * + * `CommandDialog` gets its own claim, and it is the one the registry's version + * got wrong: a dialog's accessible name has to live *inside* the dialog. The + * title is visually hidden, which is not the same as absent. + */ + +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { JSX } from "react"; +import { describe, expect, it } from "vitest"; + +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "../../src/components/command"; + +function palette(onSelect: (value: string) => void = () => undefined): JSX.Element { + return ( + + + + Nothing matches. + + + Upload a batch + ⌘U + + + Import from URL + + + + + + Edit classes + + + + + ); +} + +describe("Command", () => { + it("narrows the list as the reader types, and keeps the field focused", async () => { + const user = userEvent.setup(); + render(palette()); + + const field = screen.getByPlaceholderText("Search a command"); + await user.click(field); + await user.keyboard("class"); + + expect(document.activeElement).toBe(field); + const options = screen.getAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual(["Edit classes"]); + }); + + it("says so when nothing matches, instead of showing an empty box", async () => { + const user = userEvent.setup(); + render(palette()); + + await user.click(screen.getByPlaceholderText("Search a command")); + await user.keyboard("zzz"); + + expect(screen.queryAllByRole("option")).toHaveLength(0); + expect(screen.getByText("Nothing matches.")).toBeTruthy(); + }); + + it("moves a selection with the arrow keys that focus does not follow", async () => { + const user = userEvent.setup(); + render(palette()); + + const field = screen.getByPlaceholderText("Search a command"); + await user.click(field); + const selected = () => + screen.getAllByRole("option").find((option) => option.dataset.selected === "true"); + + expect(selected()?.textContent).toContain("Upload a batch"); + await user.keyboard("{ArrowDown}"); + expect(selected()?.textContent).toContain("Import from URL"); + // The reader never leaves the field, which is the whole point of a palette. + expect(document.activeElement).toBe(field); + }); + + it("runs the selected item on Enter", async () => { + const user = userEvent.setup(); + let ran = ""; + render(palette((value) => (ran = value))); + + await user.click(screen.getByPlaceholderText("Search a command")); + await user.keyboard("{ArrowDown}{Enter}"); + expect(ran).toBe("import"); + }); + + it("groups its items under a heading, because a flat list of verbs is not a palette", () => { + render(palette()); + const groups = [...document.querySelectorAll("[data-slot=command-group]")]; + expect(groups).toHaveLength(2); + const headings = groups.map( + (group) => group.querySelector("[cmdk-group-heading]")?.textContent, + ); + expect(headings).toEqual(["Ingest", "Schema"]); + expect(within(groups[0]!).getByRole("option", { name: /Upload a batch/ })).toBeTruthy(); + expect(within(groups[1]!).getByRole("option", { name: "Edit classes" })).toBeTruthy(); + }); +}); + +describe("CommandDialog", () => { + it("is named by a title that lives inside the dialog, hidden rather than absent", async () => { + render( + + + + + Upload a batch + + + , + ); + + const dialog = await screen.findByRole("dialog", { name: "Command palette" }); + // Rendered as a sibling of the content it names, the heading would sit in + // the page whether the palette is open or not, and would not be inside the + // surface Radix labels. + expect(within(dialog).getByText("Command palette")).toBeTruthy(); + expect(within(dialog).getByText("Run something.")).toBeTruthy(); + expect(dialog.querySelector("[data-slot=dialog-header]")?.className).toContain("sr-only"); + }); +}); diff --git a/tests/components/components.test.tsx b/tests/components/components.test.tsx index 0535a11..b7874c4 100644 --- a/tests/components/components.test.tsx +++ b/tests/components/components.test.tsx @@ -4,8 +4,8 @@ * Deliberately not an echo of every class string: pinning the design system to * whatever it looked like on the day is the mistake a restyle would pay for. * What is asserted is roles, `aria-*`, the `data-*` a consumer styles against, - * and the handful of layout facts a caller depends on. Button, Dialog, Field - * and DropdownMenu have their own files. + * and the handful of layout facts a caller depends on. Button, Dialog, Field, + * DropdownMenu, ContextMenu, Popover, Command and Drawer have their own files. */ import { render, screen } from "@testing-library/react"; @@ -14,9 +14,61 @@ import type { JSX } from "react"; import { describe, expect, it } from "vitest"; import { Alert, AlertDescription, AlertTitle } from "../../src/components/alert"; +import { + Attachment, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentTitle, + AttachmentTrigger, +} from "../../src/components/attachment"; +import { + Avatar, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarImage, +} from "../../src/components/avatar"; import { Badge } from "../../src/components/badge"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "../../src/components/breadcrumb"; +import { Button } from "../../src/components/button"; +import { ButtonGroup, ButtonGroupText } from "../../src/components/button-group"; import { Card, CardTitle } from "../../src/components/card"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "../../src/components/empty"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle, +} from "../../src/components/item"; +import { Kbd, KbdGroup } from "../../src/components/kbd"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "../../src/components/pagination"; import { Progress } from "../../src/components/progress"; +import { RadioGroup, RadioGroupItem } from "../../src/components/radio-group"; +import { ScrollArea } from "../../src/components/scroll-area"; import { Select, SelectContent, @@ -24,8 +76,11 @@ import { SelectTrigger, SelectValue, } from "../../src/components/select"; +import { Spinner } from "../../src/components/spinner"; import { Table, TableBody, TableHead, TableHeader, TableRow } from "../../src/components/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../src/components/tabs"; +import { Toggle } from "../../src/components/toggle"; +import { ToggleGroup, ToggleGroupItem } from "../../src/components/toggle-group"; describe("Alert and Badge", () => { it("announces an alert, and composes its title and description", () => { @@ -104,6 +159,16 @@ describe("Select", () => { expect(trigger.className).toContain("line-clamp-none"); expect(trigger.className).not.toContain("data-[size=default]:h-8"); expect(trigger.className).not.toContain("line-clamp-1"); + // Room to grow is only half of it, and the half that used to be missing: the + // trigger is `whitespace-nowrap` by default, so lifting the clamp alone left a + // long value on one line, overflowing the box it was supposed to have grown for. + expect(trigger.className).toContain("whitespace-normal"); + expect(trigger.className).not.toContain("whitespace-nowrap"); + }); + + it("reads a wrapped value from the left, because a native button would centre it", () => { + render(pickOne()); + expect(screen.getByTestId("model").className).toContain("text-left"); }); it("opens to a listbox floored at the closed control's width", async () => { @@ -227,3 +292,319 @@ describe("Progress", () => { expect(track).toBe(screen.getByRole("progressbar")); }); }); + +describe("Kbd and Spinner", () => { + it("renders a shortcut as a , and groups a chord into one", () => { + render( + + Cmd + K + , + ); + const keys = screen.getAllByText(/Cmd|K/); + expect(keys.map((key) => key.tagName)).toEqual(["KBD", "KBD"]); + expect(keys[0]?.getAttribute("data-slot")).toBe("kbd"); + expect(keys[0]?.parentElement?.getAttribute("data-slot")).toBe("kbd-group"); + }); + + it("announces that something is in flight, rather than spinning silently", () => { + render(); + const spinner = screen.getByRole("status", { name: "Loading" }); + expect(spinner.getAttribute("data-slot")).toBe("spinner"); + }); +}); + +/** + * A radio group is the one control where the keyboard contract is the whole + * component: the arrows move the selection, not just the focus, and Tab enters + * and leaves the group as a single stop. + */ +describe("RadioGroup", () => { + function group(): JSX.Element { + return ( + + + + + + ); + } + + it("checks exactly one option and reports it to assistive technology", () => { + render(group()); + const radios = screen.getAllByRole("radio"); + expect(radios.map((radio) => radio.getAttribute("aria-checked"))).toEqual([ + "true", + "false", + "false", + ]); + expect(screen.getByRole("radiogroup", { name: "Visibility" })).not.toBeNull(); + }); + + it("is one tab stop, and the arrows move the selection inside it", async () => { + const user = userEvent.setup(); + render(group()); + // Tab enters the group at the checked option, not at the first one, and + // leaves it in one press: three radios, one stop. + await user.tab(); + expect(document.activeElement).toBe(screen.getByRole("radio", { name: "Draft" })); + + // Press and release as two steps, because that is what a key press is. + // Radix defers the focus move to a macrotask and decides whether to *check* + // the newly focused radio by asking whether an arrow is still down; a + // keydown and keyup collapsed into one tick answer no, and the selection + // would silently stay behind while the focus ring moved on. + await user.keyboard("{ArrowDown>}"); + await user.keyboard("{/ArrowDown}"); + const internal = screen.getByRole("radio", { name: "Internal" }); + expect(document.activeElement).toBe(internal); + expect(internal.getAttribute("aria-checked")).toBe("true"); + expect(screen.getByRole("radio", { name: "Draft" }).getAttribute("aria-checked")).toBe("false"); + + await user.tab(); + expect(screen.getByRole("radiogroup").contains(document.activeElement)).toBe(false); + }); +}); + +describe("Toggle and ToggleGroup", () => { + it("reports a toggle's state as pressed, and flips it from the keyboard", async () => { + render(); + const toggle = screen.getByRole("button", { name: "Bold" }); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + + await userEvent.tab(); + await userEvent.keyboard("{ }"); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + }); + + it("keeps a single-choice group to one selected item", async () => { + render( + + + + , + ); + const items = screen.getAllByRole("radio"); + expect(items.map((item) => item.getAttribute("aria-checked"))).toEqual(["true", "false"]); + + await userEvent.click(screen.getByRole("radio", { name: "Grid" })); + expect(screen.getByRole("radio", { name: "Grid" }).getAttribute("aria-checked")).toBe("true"); + expect(screen.getByRole("radio", { name: "List" }).getAttribute("aria-checked")).toBe("false"); + }); + + it("lets a multiple-choice group hold two at once", async () => { + render( + + + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Bold" })); + await userEvent.click(screen.getByRole("button", { name: "Italic" })); + expect(screen.getAllByRole("button").map((item) => item.getAttribute("aria-pressed"))).toEqual([ + "true", + "true", + ]); + }); +}); + +describe("Breadcrumb and Pagination", () => { + it("names the trail, marks the page the reader is on, and hides the separators", () => { + render( + + + + Datasets + + + + Batch 12 + + + , + ); + + expect(screen.getByRole("navigation", { name: "breadcrumb" })).not.toBeNull(); + expect(screen.getByRole("link", { name: "Datasets" }).getAttribute("href")).toBe("/datasets"); + // The last crumb is where the reader already is: announced as current, and + // not offered as somewhere to go. + const current = screen.getByText("Batch 12"); + expect(current.getAttribute("aria-current")).toBe("page"); + expect(current.getAttribute("aria-disabled")).toBe("true"); + const separator = document.querySelector("[data-slot=breadcrumb-separator]"); + expect(separator?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("names the pager, marks the current page, and keeps every step a real link", () => { + render( + + + + + + + + 1 + + + + + 2 + + + + + + + , + ); + + expect(screen.getByRole("navigation", { name: "pagination" })).not.toBeNull(); + expect(screen.getByRole("link", { name: "Go to previous page" })).not.toBeNull(); + expect(screen.getByRole("link", { name: "Go to next page" })).not.toBeNull(); + + const current = screen.getByRole("link", { name: "2" }); + expect(current.getAttribute("aria-current")).toBe("page"); + // `data-active` is the attribute the layer's variant is keyed on, and the + // reason that variant excludes `"false"`: an inactive link renders it too. + expect(current.getAttribute("data-active")).toBe("true"); + expect(screen.getByRole("link", { name: "1" }).getAttribute("data-active")).toBe("false"); + }); +}); + +describe("Avatar", () => { + it("shows the fallback while the image has not loaded, which is the usual case", async () => { + render( + + + YA + , + ); + expect(await screen.findByText("YA")).toBeTruthy(); + }); + + it("carries its size as data, so a stack can size its overflow count to match", () => { + render( + + + A + + + B + + +3 + , + ); + const avatars = document.querySelectorAll("[data-slot=avatar]"); + expect([...avatars].map((avatar) => avatar.getAttribute("data-size"))).toEqual(["sm", "sm"]); + expect(screen.getByText("+3").getAttribute("data-slot")).toBe("avatar-group-count"); + }); +}); + +describe("ButtonGroup, Item and Empty", () => { + it("is a group to a screen reader, and every segment carries the slot the rounding reads", () => { + render( + + https:// + + , + ); + expect(screen.getByRole("group")).not.toBeNull(); + // The group rounds "the last child carrying a data-slot". A text segment + // without one is invisible to that rule and loses its corner. + expect(screen.getByText("https://").getAttribute("data-slot")).toBe("button-group-text"); + }); + + it("presents a row as a list entry with its own title, description and actions", () => { + render( + + + + batch-12.zip + 311.9 MB, uploaded today + + + + + + , + ); + expect(screen.getByRole("list")).not.toBeNull(); + expect(screen.getByText("batch-12.zip").getAttribute("data-slot")).toBe("item-title"); + expect(screen.getByText("311.9 MB, uploaded today").getAttribute("data-slot")).toBe( + "item-description", + ); + expect(screen.getByRole("button", { name: "Open" })).not.toBeNull(); + }); + + it("says what is missing and what to do about it, rather than showing a blank panel", () => { + render( + + + + No batches yet + Upload one to get started. + + + + + , + ); + expect(screen.getByText("No batches yet").getAttribute("data-slot")).toBe("empty-title"); + expect(screen.getByText("Upload one to get started.").getAttribute("data-slot")).toBe( + "empty-description", + ); + expect(screen.getByRole("button", { name: "Upload" })).not.toBeNull(); + }); +}); + +describe("Attachment", () => { + it("carries its progress as state, which is the hook every part styles from", () => { + render( + + + + batch-12.zip + 311.9 MB + + + + + broken.zip + + + , + ); + const cards = [...document.querySelectorAll("[data-slot=attachment]")]; + expect(cards.map((card) => card.getAttribute("data-state"))).toEqual(["uploading", "error"]); + }); + + it("gives the whole card a trigger that cannot submit a form by accident", () => { + render( + + + + batch-12.zip + + , + ); + const trigger = screen.getByRole("button", { name: "Open batch-12.zip" }); + expect(trigger.getAttribute("type")).toBe("button"); + }); +}); + +describe("ScrollArea", () => { + it("puts the content in a viewport, because that is what actually scrolls", () => { + render( + +

Three hundred rows.

+ , + ); + const viewport = document.querySelector("[data-slot=scroll-area-viewport]") as HTMLElement; + expect(viewport).not.toBeNull(); + expect(viewport.textContent).toBe("Three hundred rows."); + // Radix makes the viewport the scroll container; a caller who scrolls the + // root instead gets nothing, which is why the slot is part of the contract. + expect(viewport.closest("[data-slot=scroll-area]")).not.toBeNull(); + }); +}); diff --git a/tests/components/context-menu.test.tsx b/tests/components/context-menu.test.tsx new file mode 100644 index 0000000..f4f7a7a --- /dev/null +++ b/tests/components/context-menu.test.tsx @@ -0,0 +1,190 @@ +/** + * ContextMenu, on the behaviour a screen would silently lose. + * + * The one this file exists for is DropdownMenu's: **a menu leaves on the frame + * it is dismissed.** While an exit animation runs, Radix keeps the closed + * surface mounted and its dismissable layer holds `pointer-events: none` on the + * body, so the right-click meant to open the next menu is swallowed. jsdom runs + * no animation and would hide that defect — except that Radix's `Presence` + * decides whether to wait by reading `animation-name` off `getComputedStyle`, + * and jsdom resolves that from a `