diff --git a/apps/ui/content/docs/(root)/changelog.mdx b/apps/ui/content/docs/(root)/changelog.mdx index 61597e582..bd2c89900 100644 --- a/apps/ui/content/docs/(root)/changelog.mdx +++ b/apps/ui/content/docs/(root)/changelog.mdx @@ -3,6 +3,38 @@ title: Changelog description: Breaking changes, migration guides, and notable updates. --- +## August 17, 2026 + +### Tabs sizing and segmented control styles + +**`TabsList`** now supports a **`size`** prop with **`"sm"`**, **`"default"`**, and **`"lg"`** values. The size propagates to its **`TabsTab`** children, and an individual tab can override the inherited size when necessary. + +The default Tabs appearance now uses the same optical item sizing as the [Segmented Control](/ui/docs/components/segmented-control) pattern. The default rendered height is slightly smaller than before so the inset active indicator remains visually balanced beside a Button of the corresponding size. + +Tabs now imports its size map from the **`@coss/segmented-control`** registry library. **`@coss/tabs`** declares this as a registry dependency, so the shadcn CLI installs it automatically with a fresh installation or CLI-managed update. + +**Migration:** + +Existing Tabs JSX requires no changes because **`"default"`** remains the default size. If you replace your local Tabs component through the CLI, review any local customizations before accepting the overwrite: + +```bash +npx shadcn@latest add @coss/tabs +``` + +If you update **`tabs.tsx`** manually, install the shared library first and then merge the latest Tabs implementation. Copying only the new Tabs file without this dependency results in an unresolved import. + +```bash +npx shadcn@latest add @coss/segmented-control +``` + +After updating, remove local height or horizontal-padding utilities from **`TabsList`** and **`TabsTab`** only where they unintentionally override the new **`size`** API. Keep intentional product-specific overrides. + +**Agent migration prompt:** + +```text +Update the local coss Tabs component to the latest segmented-control sizing while preserving project-specific customizations. Install the shared registry dependency with `npx shadcn@latest add @coss/segmented-control`, then merge the latest @coss/tabs implementation. TabsList now accepts size="sm" | "default" | "lg" (default: "default") and propagates it to TabsTab; a TabsTab size prop overrides the inherited value. Replace the old hard-coded TabsTab height and horizontal-padding classes with segmentedControlItemSizeClassNames from the shared library. Existing Tabs JSX does not need a size prop. Audit local h-* and px-* overrides on TabsList/TabsTab and remove only those that unintentionally conflict with the new size API. Run formatting and typechecking, then visually verify default and underline Tabs in every size used by the app. +``` + ## July 31, 2026 ### Scroll Area — overscroll contain diff --git a/apps/ui/content/docs/components/meta.json b/apps/ui/content/docs/components/meta.json index 92f91f4f3..63fd3eae6 100644 --- a/apps/ui/content/docs/components/meta.json +++ b/apps/ui/content/docs/components/meta.json @@ -40,6 +40,7 @@ "radio-group", "scroll-area", "select", + "segmented-control", "separator", "sheet", "skeleton", diff --git a/apps/ui/content/docs/components/radio-group.mdx b/apps/ui/content/docs/components/radio-group.mdx index 5765ed8d9..2954521d6 100644 --- a/apps/ui/content/docs/components/radio-group.mdx +++ b/apps/ui/content/docs/components/radio-group.mdx @@ -81,6 +81,8 @@ Individual radio button. Styled wrapper for `Radio.Root` from Base UI with built For accessible labelling and validation, prefer using the `Field` component to wrap radio buttons. See the related example: [Radio Group field](/ui/docs/components/field#radio-group-field). +For tabs-style mutually exclusive choices, see the [Segmented Control](/ui/docs/components/segmented-control) pattern. + ### Disabled diff --git a/apps/ui/content/docs/components/segmented-control.mdx b/apps/ui/content/docs/components/segmented-control.mdx new file mode 100644 index 000000000..9ccbf2113 --- /dev/null +++ b/apps/ui/content/docs/components/segmented-control.mdx @@ -0,0 +1,114 @@ +--- +title: Segmented Control +description: A visual pattern for presenting related choices, navigation destinations, filters, or content views. +--- + + + +## About + +A segmented control is a visual pattern, not a standalone behavior. COSS uses the same presentation across several components while preserving the semantics, keyboard interactions, and state model of each underlying primitive. + +## Choose the right primitive + +| Intent | Use | Why | +| ------ | --- | --- | +| Choose one value in a form | [Radio Group](/ui/docs/components/radio-group) | Represents a mutually exclusive value and participates in form state. | +| Navigate to another URL or route | Navigation links | Preserves link behavior, browser history, and `aria-current`. | +| Apply an exclusive filter or mode | [Toggle Group](/ui/docs/components/toggle-group) | Represents the pressed state of an action that may be cleared. | +| Switch between related panels | [Tabs](/ui/docs/components/tabs) | Connects each tab to an associated content panel. | + +Choose the primitive from the interaction first, then apply the segmented-control styling. Visual similarity alone is not a reason to use Tabs or Toggle Group. + +## Installation + +Segmented controls are provided as particles. Install the implementation and size that match your interaction: + +| Implementation | Small | Default | Large | +| -------------- | ----- | ------- | ----- | +| Radio Group | `@coss/p-radio-group-7` | `@coss/p-radio-group-8` | `@coss/p-radio-group-9` | +| Navigation | `@coss/p-navigation-2` | `@coss/p-navigation-1` | `@coss/p-navigation-3` | + +For example, install the default Radio Group version with: + +```bash +npx shadcn@latest add @coss/p-radio-group-8 +``` + +The CLI installs the shared `segmented-control` styling library and the required primitive automatically. + +## Shared styling + +For a custom composition, install the styling library directly: + +```bash +npx shadcn@latest add @coss/segmented-control +``` + +The library exports a root class and an item recipe: + +```tsx +import { + segmentedControlItemVariants, + segmentedControlRootClassName, +} from "@/lib/segmented-control" +``` + +```tsx +const itemClassName = segmentedControlItemVariants({ + size: "default", + state: "checked", +}) +``` + +| Option | Values | Description | +| ------ | ------ | ----------- | +| `size` | `"sm" \| "default" \| "lg"` | Controls item height and horizontal padding. | +| `state` | `"checked" \| "current" \| "pressed"` | Selects the state attribute used by the underlying element. | + +Use `checked` with Radio Group, `current` with navigation links, and `pressed` with Toggle Group. Tabs retain their own animated indicator and do not use the shared state recipe. + +At the outside edges, the item padding and the surface's `p-0.5` inset combine to match the horizontal padding of the corresponding Button size. The outer segmented surface is slightly taller than that Button to optically balance its inset selected item when the controls appear next to each other. + + + +## Radio options + +Use Radio Group when the selected segment represents a mutually exclusive value, especially in forms. + +### Small Radio Group + + + +### Default Radio Group + + + +### Large Radio Group + + + +## Navigation + +Use links when each segment points to a different destination. Apply `aria-current="page"` to the active link. + +### Small Navigation + + + +### Default Navigation + + + +### Large Navigation + + + +## Related content + +Use Tabs when each segment controls an associated content panel. Tabs share the visual language of segmented controls but keep their animated indicator, orientation support, and panel semantics. + + diff --git a/apps/ui/content/docs/components/tabs.mdx b/apps/ui/content/docs/components/tabs.mdx index 60e7699f3..c08dd1dca 100644 --- a/apps/ui/content/docs/components/tabs.mdx +++ b/apps/ui/content/docs/components/tabs.mdx @@ -22,13 +22,25 @@ links: npx shadcn@latest add @coss/tabs ``` +The CLI installs the shared `@coss/segmented-control` registry dependency automatically. + -Copy and paste the following code into your project. +Install the following dependencies: + +```bash +npm install @base-ui/react class-variance-authority +``` + +Copy the shared segmented control styling into your project. + + + +Copy and paste the Tabs component into your project. @@ -65,14 +77,15 @@ import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs" Root component. Styled wrapper for `Tabs.Root` from Base UI. -| Prop | Type | Default | Description | -| --------- | ---------------------------- | ----------- | ------------------------------------------------ | -| `variant` | `"default" \| "underline"` | `"default"` | Controls the tabs styling | - ### TabsList Container for tab triggers. Styled wrapper for `Tabs.List` from Base UI. +| Prop | Type | Default | Description | +| --------- | -------------------------- | ----------- | ----------------------------------- | +| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Controls the size of all tab items. | +| `variant` | `"default" \| "underline"` | `"default"` | Controls the tabs styling. | + ### TabsTab Individual tab trigger. Styled wrapper for `Tabs.Tab` from Base UI. @@ -87,6 +100,18 @@ Visual indicator for the active tab. Styled wrapper for `Tabs.Indicator` from Ba ## Examples +For guidance on choosing between Tabs, Radio Group, Toggle Group, and navigation links, see the [Segmented Control](/ui/docs/components/segmented-control) pattern. + +Tabs use the same optical sizing as segmented controls. The default surface is slightly taller than a Button of the same size so its inset active indicator remains visually balanced alongside adjacent controls. + +### Small + + + +### Large + + + ### Underline Variant diff --git a/apps/ui/lib/docs.ts b/apps/ui/lib/docs.ts index d9f5d1169..baff695c5 100644 --- a/apps/ui/lib/docs.ts +++ b/apps/ui/lib/docs.ts @@ -1,5 +1,4 @@ export const PAGES_NEW = [ // "/docs/components/{component-name}", - "/docs/components/context-menu", - "/docs/components/otp-field", + "/docs/components/segmented-control", ]; diff --git a/apps/ui/public/r/p-navigation-1.json b/apps/ui/public/r/p-navigation-1.json new file mode 100644 index 000000000..256c65ca6 --- /dev/null +++ b/apps/ui/public/r/p-navigation-1.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-navigation-1", + "description": "Segmented navigation built with links", + "registryDependencies": [ + "@coss/segmented-control" + ], + "files": [ + { + "path": "registry/default/particles/p-navigation-1.tsx", + "content": "import {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\n\nconst itemClassName = segmentedControlItemVariants({ state: \"current\" });\n\nexport default function Particle() {\n return (\n \n );\n}\n", + "type": "registry:block" + } + ], + "categories": [ + "navigation", + "segmented control" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-navigation-2.json b/apps/ui/public/r/p-navigation-2.json new file mode 100644 index 000000000..155ad9c63 --- /dev/null +++ b/apps/ui/public/r/p-navigation-2.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-navigation-2", + "description": "Small segmented navigation built with links", + "registryDependencies": [ + "@coss/segmented-control" + ], + "files": [ + { + "path": "registry/default/particles/p-navigation-2.tsx", + "content": "import {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\n\nconst itemClassName = segmentedControlItemVariants({\n size: \"sm\",\n state: \"current\",\n});\n\nexport default function Particle() {\n return (\n \n );\n}\n", + "type": "registry:block" + } + ], + "categories": [ + "navigation", + "segmented control" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-navigation-3.json b/apps/ui/public/r/p-navigation-3.json new file mode 100644 index 000000000..bc2a61eb7 --- /dev/null +++ b/apps/ui/public/r/p-navigation-3.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-navigation-3", + "description": "Large segmented navigation built with links", + "registryDependencies": [ + "@coss/segmented-control" + ], + "files": [ + { + "path": "registry/default/particles/p-navigation-3.tsx", + "content": "import {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\n\nconst itemClassName = segmentedControlItemVariants({\n size: \"lg\",\n state: \"current\",\n});\n\nexport default function Particle() {\n return (\n \n );\n}\n", + "type": "registry:block" + } + ], + "categories": [ + "navigation", + "segmented control" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-radio-group-7.json b/apps/ui/public/r/p-radio-group-7.json new file mode 100644 index 000000000..be08b123d --- /dev/null +++ b/apps/ui/public/r/p-radio-group-7.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-radio-group-7", + "description": "Small segmented control built with a radio group", + "registryDependencies": [ + "@coss/radio-group", + "@coss/segmented-control" + ], + "files": [ + { + "path": "registry/default/particles/p-radio-group-7.tsx", + "content": "\"use client\";\n\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport {\n RadioGroupPrimitive,\n RadioPrimitive,\n} from \"@/registry/default/ui/radio-group\";\n\nconst itemClassName = segmentedControlItemVariants({\n className: \"grow\",\n size: \"sm\",\n state: \"checked\",\n});\n\nexport default function Particle() {\n return (\n \n \n Monthly\n \n \n Yearly\n \n \n );\n}\n", + "type": "registry:block" + } + ], + "categories": [ + "radio group", + "segmented control" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-radio-group-8.json b/apps/ui/public/r/p-radio-group-8.json new file mode 100644 index 000000000..5294b70e9 --- /dev/null +++ b/apps/ui/public/r/p-radio-group-8.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-radio-group-8", + "description": "Segmented control built with a radio group", + "registryDependencies": [ + "@coss/radio-group", + "@coss/segmented-control" + ], + "files": [ + { + "path": "registry/default/particles/p-radio-group-8.tsx", + "content": "\"use client\";\n\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport {\n RadioGroupPrimitive,\n RadioPrimitive,\n} from \"@/registry/default/ui/radio-group\";\n\nconst itemClassName = segmentedControlItemVariants({\n className: \"grow\",\n state: \"checked\",\n});\n\nexport default function Particle() {\n return (\n \n \n Monthly\n \n \n Yearly\n \n \n );\n}\n", + "type": "registry:block" + } + ], + "categories": [ + "radio group", + "segmented control" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-radio-group-9.json b/apps/ui/public/r/p-radio-group-9.json new file mode 100644 index 000000000..f81f3f5e5 --- /dev/null +++ b/apps/ui/public/r/p-radio-group-9.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-radio-group-9", + "description": "Large segmented control built with a radio group", + "registryDependencies": [ + "@coss/radio-group", + "@coss/segmented-control" + ], + "files": [ + { + "path": "registry/default/particles/p-radio-group-9.tsx", + "content": "\"use client\";\n\nimport {\n segmentedControlItemVariants,\n segmentedControlRootClassName,\n} from \"@/registry/default/lib/segmented-control\";\nimport {\n RadioGroupPrimitive,\n RadioPrimitive,\n} from \"@/registry/default/ui/radio-group\";\n\nconst itemClassName = segmentedControlItemVariants({\n className: \"grow\",\n size: \"lg\",\n state: \"checked\",\n});\n\nexport default function Particle() {\n return (\n \n \n Monthly\n \n \n Yearly\n \n \n );\n}\n", + "type": "registry:block" + } + ], + "categories": [ + "radio group", + "segmented control" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-tabs-1.json b/apps/ui/public/r/p-tabs-1.json index 7ecaecc3a..5f219095b 100644 --- a/apps/ui/public/r/p-tabs-1.json +++ b/apps/ui/public/r/p-tabs-1.json @@ -1,7 +1,7 @@ { "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "p-tabs-1", - "description": "Basic tabs", + "description": "Segmented control built with tabs", "registryDependencies": [ "@coss/tabs" ], diff --git a/apps/ui/public/r/p-tabs-14.json b/apps/ui/public/r/p-tabs-14.json new file mode 100644 index 000000000..ba56c5454 --- /dev/null +++ b/apps/ui/public/r/p-tabs-14.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-tabs-14", + "description": "Small segmented control built with tabs", + "registryDependencies": [ + "@coss/tabs" + ], + "files": [ + { + "path": "registry/default/particles/p-tabs-14.tsx", + "content": "import { Tabs, TabsList, TabsPanel, TabsTab } from \"@/registry/default/ui/tabs\";\n\nexport default function Particle() {\n return (\n \n \n Tab 1\n Tab 2\n Tab 3\n \n \n

\n Tab 1 content\n

\n
\n \n

\n Tab 2 content\n

\n
\n \n

\n Tab 3 content\n

\n
\n
\n );\n}\n", + "type": "registry:block" + } + ], + "categories": [ + "tabs" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-tabs-15.json b/apps/ui/public/r/p-tabs-15.json new file mode 100644 index 000000000..4e23b4a39 --- /dev/null +++ b/apps/ui/public/r/p-tabs-15.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-tabs-15", + "description": "Large segmented control built with tabs", + "registryDependencies": [ + "@coss/tabs" + ], + "files": [ + { + "path": "registry/default/particles/p-tabs-15.tsx", + "content": "import { Tabs, TabsList, TabsPanel, TabsTab } from \"@/registry/default/ui/tabs\";\n\nexport default function Particle() {\n return (\n \n \n Tab 1\n Tab 2\n Tab 3\n \n \n

\n Tab 1 content\n

\n
\n \n

\n Tab 2 content\n

\n
\n \n

\n Tab 3 content\n

\n
\n
\n );\n}\n", + "type": "registry:block" + } + ], + "categories": [ + "tabs" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index a9ccab9d9..c8034a72a 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -810,6 +810,9 @@ } ], "name": "tabs", + "registryDependencies": [ + "@coss/segmented-control" + ], "type": "registry:ui" }, { @@ -8347,6 +8350,60 @@ ], "type": "registry:block" }, + { + "categories": [ + "navigation", + "segmented control" + ], + "description": "Segmented navigation built with links", + "files": [ + { + "path": "registry/default/particles/p-navigation-1.tsx", + "type": "registry:block" + } + ], + "name": "p-navigation-1", + "registryDependencies": [ + "@coss/segmented-control" + ], + "type": "registry:block" + }, + { + "categories": [ + "navigation", + "segmented control" + ], + "description": "Small segmented navigation built with links", + "files": [ + { + "path": "registry/default/particles/p-navigation-2.tsx", + "type": "registry:block" + } + ], + "name": "p-navigation-2", + "registryDependencies": [ + "@coss/segmented-control" + ], + "type": "registry:block" + }, + { + "categories": [ + "navigation", + "segmented control" + ], + "description": "Large segmented navigation built with links", + "files": [ + { + "path": "registry/default/particles/p-navigation-3.tsx", + "type": "registry:block" + } + ], + "name": "p-navigation-3", + "registryDependencies": [ + "@coss/segmented-control" + ], + "type": "registry:block" + }, { "categories": [ "pagination" @@ -8710,6 +8767,63 @@ ], "type": "registry:block" }, + { + "categories": [ + "radio group", + "segmented control" + ], + "description": "Small segmented control built with a radio group", + "files": [ + { + "path": "registry/default/particles/p-radio-group-7.tsx", + "type": "registry:block" + } + ], + "name": "p-radio-group-7", + "registryDependencies": [ + "@coss/radio-group", + "@coss/segmented-control" + ], + "type": "registry:block" + }, + { + "categories": [ + "radio group", + "segmented control" + ], + "description": "Segmented control built with a radio group", + "files": [ + { + "path": "registry/default/particles/p-radio-group-8.tsx", + "type": "registry:block" + } + ], + "name": "p-radio-group-8", + "registryDependencies": [ + "@coss/radio-group", + "@coss/segmented-control" + ], + "type": "registry:block" + }, + { + "categories": [ + "radio group", + "segmented control" + ], + "description": "Large segmented control built with a radio group", + "files": [ + { + "path": "registry/default/particles/p-radio-group-9.tsx", + "type": "registry:block" + } + ], + "name": "p-radio-group-9", + "registryDependencies": [ + "@coss/radio-group", + "@coss/segmented-control" + ], + "type": "registry:block" + }, { "categories": [ "scroll area" @@ -10341,7 +10455,7 @@ "categories": [ "tabs" ], - "description": "Basic tabs", + "description": "Segmented control built with tabs", "files": [ { "path": "registry/default/particles/p-tabs-1.tsx", @@ -10594,6 +10708,40 @@ ], "type": "registry:block" }, + { + "categories": [ + "tabs" + ], + "description": "Small segmented control built with tabs", + "files": [ + { + "path": "registry/default/particles/p-tabs-14.tsx", + "type": "registry:block" + } + ], + "name": "p-tabs-14", + "registryDependencies": [ + "@coss/tabs" + ], + "type": "registry:block" + }, + { + "categories": [ + "tabs" + ], + "description": "Large segmented control built with tabs", + "files": [ + { + "path": "registry/default/particles/p-tabs-15.tsx", + "type": "registry:block" + } + ], + "name": "p-tabs-15", + "registryDependencies": [ + "@coss/tabs" + ], + "type": "registry:block" + }, { "categories": [ "textarea" @@ -11813,6 +11961,19 @@ "dependency": "geist" } }, + { + "dependencies": [ + "class-variance-authority" + ], + "files": [ + { + "path": "registry/default/lib/segmented-control.ts", + "type": "registry:lib" + } + ], + "name": "segmented-control", + "type": "registry:lib" + }, { "dependencies": [ "clsx", diff --git a/apps/ui/public/r/segmented-control.json b/apps/ui/public/r/segmented-control.json new file mode 100644 index 000000000..7e7e776ac --- /dev/null +++ b/apps/ui/public/r/segmented-control.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "segmented-control", + "dependencies": [ + "class-variance-authority" + ], + "files": [ + { + "path": "registry/default/lib/segmented-control.ts", + "content": "import { cva } from \"class-variance-authority\";\n\nexport type SegmentedControlSize = \"default\" | \"lg\" | \"sm\";\n\nexport const segmentedControlItemSizeClassNames: Record<\n SegmentedControlSize,\n string\n> = {\n default: \"h-8.5 px-[calc(--spacing(2.5)-1px)] sm:h-7.5\",\n lg: \"h-9.5 px-[calc(--spacing(3)-1px)] sm:h-8.5\",\n sm: \"h-7.5 px-[calc(--spacing(2)-1px)] sm:h-6.5\",\n};\n\nexport const segmentedControlRootClassName =\n \"relative z-0 flex w-fit items-center justify-center gap-0.5 rounded-lg bg-muted p-0.5\";\n\nexport const segmentedControlItemVariants = cva(\n \"relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base text-muted-foreground/72 outline-2 outline-transparent transition-[outline-color] hover:bg-transparent hover:text-muted-foreground focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm\",\n {\n defaultVariants: {\n size: \"default\",\n },\n variants: {\n size: segmentedControlItemSizeClassNames,\n state: {\n checked:\n \"data-checked:bg-background data-checked:text-foreground data-checked:shadow-sm/5 dark:data-checked:bg-input\",\n current:\n \"aria-[current=page]:bg-background aria-[current=page]:text-foreground aria-[current=page]:shadow-sm/5 dark:aria-[current=page]:bg-input\",\n pressed:\n \"data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm/5 dark:data-pressed:bg-input\",\n },\n },\n },\n);\n", + "type": "registry:lib" + } + ], + "type": "registry:lib" +} \ No newline at end of file diff --git a/apps/ui/public/r/tabs.json b/apps/ui/public/r/tabs.json index 0760cba83..2628dffdf 100644 --- a/apps/ui/public/r/tabs.json +++ b/apps/ui/public/r/tabs.json @@ -4,10 +4,13 @@ "dependencies": [ "@base-ui/react" ], + "registryDependencies": [ + "@coss/segmented-control" + ], "files": [ { "path": "registry/default/ui/tabs.tsx", - "content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport type React from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\nexport type TabsVariant = \"default\" | \"underline\";\n\nexport function Tabs({\n className,\n ...props\n}: TabsPrimitive.Root.Props): React.ReactElement {\n return (\n \n );\n}\n\nexport function TabsList({\n variant = \"default\",\n className,\n children,\n ...props\n}: TabsPrimitive.List.Props & {\n variant?: TabsVariant;\n}): React.ReactElement {\n return (\n \n {children}\n \n \n );\n}\n\nexport function TabsTab({\n className,\n ...props\n}: TabsPrimitive.Tab.Props): React.ReactElement {\n return (\n \n );\n}\n\nexport function TabsPanel({\n className,\n ...props\n}: TabsPrimitive.Panel.Props): React.ReactElement {\n return (\n \n );\n}\n\nexport { TabsPrimitive, TabsTab as TabsTrigger, TabsPanel as TabsContent };\n", + "content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport * as React from \"react\";\nimport {\n type SegmentedControlSize,\n segmentedControlItemSizeClassNames,\n} from \"@/registry/default/lib/segmented-control\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\ntype TabsVariant = \"default\" | \"underline\";\ntype TabsSize = SegmentedControlSize;\n\nconst TabsListContext: React.Context =\n React.createContext(\"default\");\n\nexport function Tabs({\n className,\n ...props\n}: TabsPrimitive.Root.Props): React.ReactElement {\n return (\n \n );\n}\n\nexport function TabsList({\n variant = \"default\",\n size = \"default\",\n className,\n children,\n ...props\n}: TabsPrimitive.List.Props & {\n size?: TabsSize;\n variant?: TabsVariant;\n}): React.ReactElement {\n return (\n \n \n {children}\n \n \n \n );\n}\n\nexport function TabsTab({\n className,\n size,\n ...props\n}: TabsPrimitive.Tab.Props & {\n size?: TabsSize;\n}): React.ReactElement {\n const contextSize: TabsSize = React.useContext(TabsListContext);\n const resolvedSize: TabsSize = size ?? contextSize;\n\n return (\n \n );\n}\n\nexport function TabsPanel({\n className,\n ...props\n}: TabsPrimitive.Panel.Props): React.ReactElement {\n return (\n \n );\n}\n\nexport {\n TabsPrimitive,\n TabsTab as TabsTrigger,\n TabsPanel as TabsContent,\n type TabsSize,\n type TabsVariant,\n};\n", "type": "registry:ui" } ], diff --git a/apps/ui/registry.json b/apps/ui/registry.json index a9ccab9d9..c8034a72a 100644 --- a/apps/ui/registry.json +++ b/apps/ui/registry.json @@ -810,6 +810,9 @@ } ], "name": "tabs", + "registryDependencies": [ + "@coss/segmented-control" + ], "type": "registry:ui" }, { @@ -8347,6 +8350,60 @@ ], "type": "registry:block" }, + { + "categories": [ + "navigation", + "segmented control" + ], + "description": "Segmented navigation built with links", + "files": [ + { + "path": "registry/default/particles/p-navigation-1.tsx", + "type": "registry:block" + } + ], + "name": "p-navigation-1", + "registryDependencies": [ + "@coss/segmented-control" + ], + "type": "registry:block" + }, + { + "categories": [ + "navigation", + "segmented control" + ], + "description": "Small segmented navigation built with links", + "files": [ + { + "path": "registry/default/particles/p-navigation-2.tsx", + "type": "registry:block" + } + ], + "name": "p-navigation-2", + "registryDependencies": [ + "@coss/segmented-control" + ], + "type": "registry:block" + }, + { + "categories": [ + "navigation", + "segmented control" + ], + "description": "Large segmented navigation built with links", + "files": [ + { + "path": "registry/default/particles/p-navigation-3.tsx", + "type": "registry:block" + } + ], + "name": "p-navigation-3", + "registryDependencies": [ + "@coss/segmented-control" + ], + "type": "registry:block" + }, { "categories": [ "pagination" @@ -8710,6 +8767,63 @@ ], "type": "registry:block" }, + { + "categories": [ + "radio group", + "segmented control" + ], + "description": "Small segmented control built with a radio group", + "files": [ + { + "path": "registry/default/particles/p-radio-group-7.tsx", + "type": "registry:block" + } + ], + "name": "p-radio-group-7", + "registryDependencies": [ + "@coss/radio-group", + "@coss/segmented-control" + ], + "type": "registry:block" + }, + { + "categories": [ + "radio group", + "segmented control" + ], + "description": "Segmented control built with a radio group", + "files": [ + { + "path": "registry/default/particles/p-radio-group-8.tsx", + "type": "registry:block" + } + ], + "name": "p-radio-group-8", + "registryDependencies": [ + "@coss/radio-group", + "@coss/segmented-control" + ], + "type": "registry:block" + }, + { + "categories": [ + "radio group", + "segmented control" + ], + "description": "Large segmented control built with a radio group", + "files": [ + { + "path": "registry/default/particles/p-radio-group-9.tsx", + "type": "registry:block" + } + ], + "name": "p-radio-group-9", + "registryDependencies": [ + "@coss/radio-group", + "@coss/segmented-control" + ], + "type": "registry:block" + }, { "categories": [ "scroll area" @@ -10341,7 +10455,7 @@ "categories": [ "tabs" ], - "description": "Basic tabs", + "description": "Segmented control built with tabs", "files": [ { "path": "registry/default/particles/p-tabs-1.tsx", @@ -10594,6 +10708,40 @@ ], "type": "registry:block" }, + { + "categories": [ + "tabs" + ], + "description": "Small segmented control built with tabs", + "files": [ + { + "path": "registry/default/particles/p-tabs-14.tsx", + "type": "registry:block" + } + ], + "name": "p-tabs-14", + "registryDependencies": [ + "@coss/tabs" + ], + "type": "registry:block" + }, + { + "categories": [ + "tabs" + ], + "description": "Large segmented control built with tabs", + "files": [ + { + "path": "registry/default/particles/p-tabs-15.tsx", + "type": "registry:block" + } + ], + "name": "p-tabs-15", + "registryDependencies": [ + "@coss/tabs" + ], + "type": "registry:block" + }, { "categories": [ "textarea" @@ -11813,6 +11961,19 @@ "dependency": "geist" } }, + { + "dependencies": [ + "class-variance-authority" + ], + "files": [ + { + "path": "registry/default/lib/segmented-control.ts", + "type": "registry:lib" + } + ], + "name": "segmented-control", + "type": "registry:lib" + }, { "dependencies": [ "clsx", diff --git a/apps/ui/registry/__index__.tsx b/apps/ui/registry/__index__.tsx index 9c4d68d6c..7e3b89b7c 100644 --- a/apps/ui/registry/__index__.tsx +++ b/apps/ui/registry/__index__.tsx @@ -857,7 +857,7 @@ export const Index: Record = { name: "tabs", description: "", type: "registry:ui", - registryDependencies: undefined, + registryDependencies: ["@coss/segmented-control"], files: [{ path: "registry/default/ui/tabs.tsx", type: "registry:ui", @@ -7189,6 +7189,60 @@ export const Index: Record = { categories: ["number field","input"], meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-64"}, }, + "p-navigation-1": { + name: "p-navigation-1", + description: "Segmented navigation built with links", + type: "registry:block", + registryDependencies: ["@coss/segmented-control"], + files: [{ + path: "registry/default/particles/p-navigation-1.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-navigation-1.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["navigation","segmented control"], + meta: undefined, + }, + "p-navigation-2": { + name: "p-navigation-2", + description: "Small segmented navigation built with links", + type: "registry:block", + registryDependencies: ["@coss/segmented-control"], + files: [{ + path: "registry/default/particles/p-navigation-2.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-navigation-2.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["navigation","segmented control"], + meta: undefined, + }, + "p-navigation-3": { + name: "p-navigation-3", + description: "Large segmented navigation built with links", + type: "registry:block", + registryDependencies: ["@coss/segmented-control"], + files: [{ + path: "registry/default/particles/p-navigation-3.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-navigation-3.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["navigation","segmented control"], + meta: undefined, + }, "p-pagination-1": { name: "p-pagination-1", description: "Pagination example", @@ -7495,6 +7549,60 @@ export const Index: Record = { categories: ["radio group","form"], meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-[320px]"}, }, + "p-radio-group-7": { + name: "p-radio-group-7", + description: "Small segmented control built with a radio group", + type: "registry:block", + registryDependencies: ["@coss/radio-group","@coss/segmented-control"], + files: [{ + path: "registry/default/particles/p-radio-group-7.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-radio-group-7.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["radio group","segmented control"], + meta: undefined, + }, + "p-radio-group-8": { + name: "p-radio-group-8", + description: "Segmented control built with a radio group", + type: "registry:block", + registryDependencies: ["@coss/radio-group","@coss/segmented-control"], + files: [{ + path: "registry/default/particles/p-radio-group-8.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-radio-group-8.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["radio group","segmented control"], + meta: undefined, + }, + "p-radio-group-9": { + name: "p-radio-group-9", + description: "Large segmented control built with a radio group", + type: "registry:block", + registryDependencies: ["@coss/radio-group","@coss/segmented-control"], + files: [{ + path: "registry/default/particles/p-radio-group-9.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-radio-group-9.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["radio group","segmented control"], + meta: undefined, + }, "p-scroll-area-1": { name: "p-scroll-area-1", description: "Basic scroll area", @@ -8847,7 +8955,7 @@ export const Index: Record = { }, "p-tabs-1": { name: "p-tabs-1", - description: "Basic tabs", + description: "Segmented control built with tabs", type: "registry:block", registryDependencies: ["@coss/tabs"], files: [{ @@ -9079,6 +9187,42 @@ export const Index: Record = { categories: ["tabs","tooltip"], meta: undefined, }, + "p-tabs-14": { + name: "p-tabs-14", + description: "Small segmented control built with tabs", + type: "registry:block", + registryDependencies: ["@coss/tabs"], + files: [{ + path: "registry/default/particles/p-tabs-14.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-tabs-14.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["tabs"], + meta: undefined, + }, + "p-tabs-15": { + name: "p-tabs-15", + description: "Large segmented control built with tabs", + type: "registry:block", + registryDependencies: ["@coss/tabs"], + files: [{ + path: "registry/default/particles/p-tabs-15.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-tabs-15.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["tabs"], + meta: undefined, + }, "p-textarea-1": { name: "p-textarea-1", description: "Basic textarea", @@ -9989,6 +10133,24 @@ export const Index: Record = { categories: undefined, meta: undefined, }, + "segmented-control": { + name: "segmented-control", + description: "", + type: "registry:lib", + registryDependencies: undefined, + files: [{ + path: "registry/default/lib/segmented-control.ts", + type: "registry:lib", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/lib/segmented-control.ts") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, "utils": { name: "utils", description: "", diff --git a/apps/ui/registry/default/lib/segmented-control.ts b/apps/ui/registry/default/lib/segmented-control.ts new file mode 100644 index 000000000..bcef88500 --- /dev/null +++ b/apps/ui/registry/default/lib/segmented-control.ts @@ -0,0 +1,35 @@ +import { cva } from "class-variance-authority"; + +export type SegmentedControlSize = "default" | "lg" | "sm"; + +export const segmentedControlItemSizeClassNames: Record< + SegmentedControlSize, + string +> = { + default: "h-8.5 px-[calc(--spacing(2.5)-1px)] sm:h-7.5", + lg: "h-9.5 px-[calc(--spacing(3)-1px)] sm:h-8.5", + sm: "h-7.5 px-[calc(--spacing(2)-1px)] sm:h-6.5", +}; + +export const segmentedControlRootClassName = + "relative z-0 flex w-fit items-center justify-center gap-0.5 rounded-lg bg-muted p-0.5"; + +export const segmentedControlItemVariants = cva( + "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base text-muted-foreground/72 outline-2 outline-transparent transition-[outline-color] hover:bg-transparent hover:text-muted-foreground focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm", + { + defaultVariants: { + size: "default", + }, + variants: { + size: segmentedControlItemSizeClassNames, + state: { + checked: + "data-checked:bg-background data-checked:text-foreground data-checked:shadow-sm/5 dark:data-checked:bg-input", + current: + "aria-[current=page]:bg-background aria-[current=page]:text-foreground aria-[current=page]:shadow-sm/5 dark:aria-[current=page]:bg-input", + pressed: + "data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm/5 dark:data-pressed:bg-input", + }, + }, + }, +); diff --git a/apps/ui/registry/default/particles/p-navigation-1.tsx b/apps/ui/registry/default/particles/p-navigation-1.tsx new file mode 100644 index 000000000..c5c23baa8 --- /dev/null +++ b/apps/ui/registry/default/particles/p-navigation-1.tsx @@ -0,0 +1,24 @@ +import { + segmentedControlItemVariants, + segmentedControlRootClassName, +} from "@/registry/default/lib/segmented-control"; + +const itemClassName = segmentedControlItemVariants({ state: "current" }); + +export default function Particle() { + return ( + + ); +} diff --git a/apps/ui/registry/default/particles/p-navigation-2.tsx b/apps/ui/registry/default/particles/p-navigation-2.tsx new file mode 100644 index 000000000..ccce03d86 --- /dev/null +++ b/apps/ui/registry/default/particles/p-navigation-2.tsx @@ -0,0 +1,27 @@ +import { + segmentedControlItemVariants, + segmentedControlRootClassName, +} from "@/registry/default/lib/segmented-control"; + +const itemClassName = segmentedControlItemVariants({ + size: "sm", + state: "current", +}); + +export default function Particle() { + return ( + + ); +} diff --git a/apps/ui/registry/default/particles/p-navigation-3.tsx b/apps/ui/registry/default/particles/p-navigation-3.tsx new file mode 100644 index 000000000..53dd2ce37 --- /dev/null +++ b/apps/ui/registry/default/particles/p-navigation-3.tsx @@ -0,0 +1,27 @@ +import { + segmentedControlItemVariants, + segmentedControlRootClassName, +} from "@/registry/default/lib/segmented-control"; + +const itemClassName = segmentedControlItemVariants({ + size: "lg", + state: "current", +}); + +export default function Particle() { + return ( + + ); +} diff --git a/apps/ui/registry/default/particles/p-radio-group-7.tsx b/apps/ui/registry/default/particles/p-radio-group-7.tsx new file mode 100644 index 000000000..1e31a259c --- /dev/null +++ b/apps/ui/registry/default/particles/p-radio-group-7.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { + segmentedControlItemVariants, + segmentedControlRootClassName, +} from "@/registry/default/lib/segmented-control"; +import { + RadioGroupPrimitive, + RadioPrimitive, +} from "@/registry/default/ui/radio-group"; + +const itemClassName = segmentedControlItemVariants({ + className: "grow", + size: "sm", + state: "checked", +}); + +export default function Particle() { + return ( + + + Monthly + + + Yearly + + + ); +} diff --git a/apps/ui/registry/default/particles/p-radio-group-8.tsx b/apps/ui/registry/default/particles/p-radio-group-8.tsx new file mode 100644 index 000000000..40683b050 --- /dev/null +++ b/apps/ui/registry/default/particles/p-radio-group-8.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { + segmentedControlItemVariants, + segmentedControlRootClassName, +} from "@/registry/default/lib/segmented-control"; +import { + RadioGroupPrimitive, + RadioPrimitive, +} from "@/registry/default/ui/radio-group"; + +const itemClassName = segmentedControlItemVariants({ + className: "grow", + state: "checked", +}); + +export default function Particle() { + return ( + + + Monthly + + + Yearly + + + ); +} diff --git a/apps/ui/registry/default/particles/p-radio-group-9.tsx b/apps/ui/registry/default/particles/p-radio-group-9.tsx new file mode 100644 index 000000000..8c8ddab39 --- /dev/null +++ b/apps/ui/registry/default/particles/p-radio-group-9.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { + segmentedControlItemVariants, + segmentedControlRootClassName, +} from "@/registry/default/lib/segmented-control"; +import { + RadioGroupPrimitive, + RadioPrimitive, +} from "@/registry/default/ui/radio-group"; + +const itemClassName = segmentedControlItemVariants({ + className: "grow", + size: "lg", + state: "checked", +}); + +export default function Particle() { + return ( + + + Monthly + + + Yearly + + + ); +} diff --git a/apps/ui/registry/default/particles/p-tabs-14.tsx b/apps/ui/registry/default/particles/p-tabs-14.tsx new file mode 100644 index 000000000..16895371e --- /dev/null +++ b/apps/ui/registry/default/particles/p-tabs-14.tsx @@ -0,0 +1,28 @@ +import { Tabs, TabsList, TabsPanel, TabsTab } from "@/registry/default/ui/tabs"; + +export default function Particle() { + return ( + + + Tab 1 + Tab 2 + Tab 3 + + +

+ Tab 1 content +

+
+ +

+ Tab 2 content +

+
+ +

+ Tab 3 content +

+
+
+ ); +} diff --git a/apps/ui/registry/default/particles/p-tabs-15.tsx b/apps/ui/registry/default/particles/p-tabs-15.tsx new file mode 100644 index 000000000..092228198 --- /dev/null +++ b/apps/ui/registry/default/particles/p-tabs-15.tsx @@ -0,0 +1,28 @@ +import { Tabs, TabsList, TabsPanel, TabsTab } from "@/registry/default/ui/tabs"; + +export default function Particle() { + return ( + + + Tab 1 + Tab 2 + Tab 3 + + +

+ Tab 1 content +

+
+ +

+ Tab 2 content +

+
+ +

+ Tab 3 content +

+
+
+ ); +} diff --git a/apps/ui/registry/default/ui/tabs.tsx b/apps/ui/registry/default/ui/tabs.tsx index 7a4b82ee7..17b83777e 100644 --- a/apps/ui/registry/default/ui/tabs.tsx +++ b/apps/ui/registry/default/ui/tabs.tsx @@ -1,10 +1,18 @@ "use client"; import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; -import type React from "react"; +import * as React from "react"; +import { + type SegmentedControlSize, + segmentedControlItemSizeClassNames, +} from "@/registry/default/lib/segmented-control"; import { cn } from "@/registry/default/lib/utils"; -export type TabsVariant = "default" | "underline"; +type TabsVariant = "default" | "underline"; +type TabsSize = SegmentedControlSize; + +const TabsListContext: React.Context = + React.createContext("default"); export function Tabs({ className, @@ -24,10 +32,12 @@ export function Tabs({ export function TabsList({ variant = "default", + size = "default", className, children, ...props }: TabsPrimitive.List.Props & { + size?: TabsSize; variant?: TabsVariant; }): React.ReactElement { return ( @@ -40,10 +50,13 @@ export function TabsList({ : "data-[orientation=vertical]:px-1 data-[orientation=horizontal]:py-1 *:data-[slot=tabs-tab]:hover:bg-accent", className, )} + data-size={size} data-slot="tabs-list" {...props} > - {children} + + {children} + @@ -86,4 +107,10 @@ export function TabsPanel({ ); } -export { TabsPrimitive, TabsTab as TabsTrigger, TabsPanel as TabsContent }; +export { + TabsPrimitive, + TabsTab as TabsTrigger, + TabsPanel as TabsContent, + type TabsSize, + type TabsVariant, +}; diff --git a/apps/ui/registry/registry-categories.ts b/apps/ui/registry/registry-categories.ts index 3891865fd..dd8e332f1 100644 --- a/apps/ui/registry/registry-categories.ts +++ b/apps/ui/registry/registry-categories.ts @@ -40,6 +40,7 @@ export const registryCategories = [ "label", "menu", "meter", + "navigation", "number field", "otp field", "pagination", @@ -49,6 +50,7 @@ export const registryCategories = [ "radio group", "scroll area", "select", + "segmented control", "separator", "sheet", "skeleton", diff --git a/apps/ui/registry/registry-lib.ts b/apps/ui/registry/registry-lib.ts index 9d4db687b..7cedb75f6 100644 --- a/apps/ui/registry/registry-lib.ts +++ b/apps/ui/registry/registry-lib.ts @@ -1,6 +1,17 @@ import type { Registry } from "shadcn/schema"; export const lib: Registry["items"] = [ + { + dependencies: ["class-variance-authority"], + files: [ + { + path: "lib/segmented-control.ts", + type: "registry:lib", + }, + ], + name: "segmented-control", + type: "registry:lib", + }, { dependencies: ["clsx", "tailwind-merge"], files: [ diff --git a/apps/ui/registry/registry-particles.ts b/apps/ui/registry/registry-particles.ts index 00d65451c..843eee8e8 100644 --- a/apps/ui/registry/registry-particles.ts +++ b/apps/ui/registry/registry-particles.ts @@ -3954,6 +3954,30 @@ export const particles: ParticleItem[] = [ registryDependencies: ["@coss/number-field"], type: "registry:block", }, + { + categories: categories("navigation", "segmented control"), + description: "Segmented navigation built with links", + files: [{ path: "particles/p-navigation-1.tsx", type: "registry:block" }], + name: "p-navigation-1", + registryDependencies: ["@coss/segmented-control"], + type: "registry:block", + }, + { + categories: categories("navigation", "segmented control"), + description: "Small segmented navigation built with links", + files: [{ path: "particles/p-navigation-2.tsx", type: "registry:block" }], + name: "p-navigation-2", + registryDependencies: ["@coss/segmented-control"], + type: "registry:block", + }, + { + categories: categories("navigation", "segmented control"), + description: "Large segmented navigation built with links", + files: [{ path: "particles/p-navigation-3.tsx", type: "registry:block" }], + name: "p-navigation-3", + registryDependencies: ["@coss/segmented-control"], + type: "registry:block", + }, { categories: categories("pagination"), description: "Pagination example", @@ -4147,6 +4171,30 @@ export const particles: ParticleItem[] = [ ], type: "registry:block", }, + { + categories: categories("radio group", "segmented control"), + description: "Small segmented control built with a radio group", + files: [{ path: "particles/p-radio-group-7.tsx", type: "registry:block" }], + name: "p-radio-group-7", + registryDependencies: ["@coss/radio-group", "@coss/segmented-control"], + type: "registry:block", + }, + { + categories: categories("radio group", "segmented control"), + description: "Segmented control built with a radio group", + files: [{ path: "particles/p-radio-group-8.tsx", type: "registry:block" }], + name: "p-radio-group-8", + registryDependencies: ["@coss/radio-group", "@coss/segmented-control"], + type: "registry:block", + }, + { + categories: categories("radio group", "segmented control"), + description: "Large segmented control built with a radio group", + files: [{ path: "particles/p-radio-group-9.tsx", type: "registry:block" }], + name: "p-radio-group-9", + registryDependencies: ["@coss/radio-group", "@coss/segmented-control"], + type: "registry:block", + }, { categories: categories("scroll area"), description: "Basic scroll area", @@ -5110,7 +5158,7 @@ export const particles: ParticleItem[] = [ }, { categories: categories("tabs"), - description: "Basic tabs", + description: "Segmented control built with tabs", files: [{ path: "particles/p-tabs-1.tsx", type: "registry:block" }], name: "p-tabs-1", registryDependencies: ["@coss/tabs"], @@ -5231,6 +5279,22 @@ export const particles: ParticleItem[] = [ registryDependencies: ["@coss/tabs", "@coss/tooltip"], type: "registry:block", }, + { + categories: categories("tabs"), + description: "Small segmented control built with tabs", + files: [{ path: "particles/p-tabs-14.tsx", type: "registry:block" }], + name: "p-tabs-14", + registryDependencies: ["@coss/tabs"], + type: "registry:block", + }, + { + categories: categories("tabs"), + description: "Large segmented control built with tabs", + files: [{ path: "particles/p-tabs-15.tsx", type: "registry:block" }], + name: "p-tabs-15", + registryDependencies: ["@coss/tabs"], + type: "registry:block", + }, { categories: categories("textarea"), description: "Basic textarea", diff --git a/apps/ui/registry/registry-ui.ts b/apps/ui/registry/registry-ui.ts index d144a68e9..17c9e01ef 100644 --- a/apps/ui/registry/registry-ui.ts +++ b/apps/ui/registry/registry-ui.ts @@ -701,6 +701,7 @@ export const ui: Registry["items"] = [ }, ], name: "tabs", + registryDependencies: ["@coss/segmented-control"], type: "registry:ui", }, { diff --git a/apps/ui/skills/coss-particles/SKILL.md b/apps/ui/skills/coss-particles/SKILL.md index 7f2b073cc..2978e5367 100644 --- a/apps/ui/skills/coss-particles/SKILL.md +++ b/apps/ui/skills/coss-particles/SKILL.md @@ -37,7 +37,7 @@ Run the generator script from the coss repo root: node apps/ui/scripts/generate-particle-index.cjs ``` -Total: **500 particles** across **53 component types** +Total: **508 particles** across **54 component types** ## Component types @@ -71,13 +71,14 @@ Total: **500 particles** across **53 component types** - [kbd](#kbd) (1) - [menu](#menu) (9) - [meter](#meter) (4) +- [navigation](#navigation) (3) - [number-field](#number-field) (11) - [otp-field](#otp-field) (9) - [pagination](#pagination) (3) - [popover](#popover) (4) - [preview-card](#preview-card) (1) - [progress](#progress) (3) -- [radio-group](#radio-group) (6) +- [radio-group](#radio-group) (9) - [scroll-area](#scroll-area) (5) - [select](#select) (23) - [separator](#separator) (1) @@ -87,7 +88,7 @@ Total: **500 particles** across **53 component types** - [spinner](#spinner) (1) - [switch](#switch) (9) - [table](#table) (8) -- [tabs](#tabs) (13) +- [tabs](#tabs) (15) - [textarea](#textarea) (15) - [toast](#toast) (13) - [toggle](#toggle) (8) @@ -512,6 +513,12 @@ Total: **500 particles** across **53 component types** - Meter with formatted value | [JSON](https://coss.com/ui/r/p-meter-3.json) - Meter with range | [JSON](https://coss.com/ui/r/p-meter-4.json) +### navigation + +- Segmented navigation built with links | [JSON](https://coss.com/ui/r/p-navigation-1.json) +- Small segmented navigation built with links | [JSON](https://coss.com/ui/r/p-navigation-2.json) +- Large segmented navigation built with links | [JSON](https://coss.com/ui/r/p-navigation-3.json) + ### number-field - Basic number field | [JSON](https://coss.com/ui/r/p-number-field-1.json) @@ -569,6 +576,9 @@ Total: **500 particles** across **53 component types** - Radio group card | [JSON](https://coss.com/ui/r/p-radio-group-4.json) - Radio group in form | [JSON](https://coss.com/ui/r/p-radio-group-5.json) - Theme selector with image cards | [JSON](https://coss.com/ui/r/p-radio-group-6.json) +- Small segmented control built with a radio group | [JSON](https://coss.com/ui/r/p-radio-group-7.json) +- Segmented control built with a radio group | [JSON](https://coss.com/ui/r/p-radio-group-8.json) +- Large segmented control built with a radio group | [JSON](https://coss.com/ui/r/p-radio-group-9.json) ### scroll-area @@ -674,7 +684,7 @@ Total: **500 particles** across **53 component types** ### tabs -- Basic tabs | [JSON](https://coss.com/ui/r/p-tabs-1.json) +- Segmented control built with tabs | [JSON](https://coss.com/ui/r/p-tabs-1.json) - Tabs with underline | [JSON](https://coss.com/ui/r/p-tabs-2.json) - Vertical tabs | [JSON](https://coss.com/ui/r/p-tabs-3.json) - Vertical tabs with underline | [JSON](https://coss.com/ui/r/p-tabs-4.json) @@ -687,6 +697,8 @@ Total: **500 particles** across **53 component types** - Vertical tabs with underline and icon before name | [JSON](https://coss.com/ui/r/p-tabs-11.json) - Tabs with icon only and count badge | [JSON](https://coss.com/ui/r/p-tabs-12.json) - Tabs with icon only and grouped tooltips | [JSON](https://coss.com/ui/r/p-tabs-13.json) +- Small segmented control built with tabs | [JSON](https://coss.com/ui/r/p-tabs-14.json) +- Large segmented control built with tabs | [JSON](https://coss.com/ui/r/p-tabs-15.json) ### textarea diff --git a/apps/ui/skills/coss/SKILL.md b/apps/ui/skills/coss/SKILL.md index 8cbda0751..c17ca3053 100644 --- a/apps/ui/skills/coss/SKILL.md +++ b/apps/ui/skills/coss/SKILL.md @@ -1,6 +1,6 @@ --- name: coss -description: Helps implement coss UI components correctly. Use when building UIs with coss primitives (buttons, dialogs, selects, forms, menus, tabs, inputs, toasts, etc.), migrating from shadcn/Radix to coss/Base UI, composing trigger-based overlays, or troubleshooting coss component behavior. Covers imports, accessibility, Tailwind styling, and common pitfalls. +description: Helps implement coss UI components correctly. Use when building UIs with coss primitives and patterns (buttons, dialogs, selects, forms, menus, tabs, segmented controls, inputs, toasts, etc.), migrating from shadcn/Radix to coss/Base UI, composing trigger-based overlays, or troubleshooting coss component behavior. Covers imports, accessibility, Tailwind styling, and common pitfalls. compatibility: Requires Tailwind CSS v4 and @base-ui/react. Designed for React projects using the coss component registry. license: MIT metadata: @@ -60,6 +60,7 @@ Rule references (read on demand when the task touches these areas): - `./references/rules/forms.md` — Field composition, validation, input patterns - `./references/rules/composition.md` — Trigger/popup hierarchies, grouped controls - `./references/rules/migration.md` — shadcn/Radix to coss/Base UI migration patterns +- `./references/segmented-control.md` — choose Radio Group, links, Toggle Group, or Tabs before applying segmented-control styling - `./references/portal-props.md` — optional `portalProps` on composed popups and toast providers (`keepMounted`, `container`, which surfaces support it) ## Component discovery @@ -115,4 +116,3 @@ Before returning code: - composition structure is valid for selected primitive(s) - accessibility and explicit control types (`button`, `input`, etc.) are present - migration-sensitive flows are verified (type/lint, keyboard/a11y behavior, and SSR-sensitive primitives like Select/Command) - diff --git a/apps/ui/skills/coss/references/component-registry.md b/apps/ui/skills/coss/references/component-registry.md index 1a3a2b291..9fd564bce 100644 --- a/apps/ui/skills/coss/references/component-registry.md +++ b/apps/ui/skills/coss/references/component-registry.md @@ -4,6 +4,9 @@ Use this file to quickly identify the right coss primitive for a UI task. Each e For optional **`portalProps`** on composed `*Popup` components and **toast** providers (Base UI portal forwarding), see `./references/portal-props.md`. +## Cross-component patterns +- **Segmented Control** — Shared visual treatment for radio choices, navigation links, exclusive toggles, or tabbed panels; choose semantics first. `./references/segmented-control.md` + ## Overlays & Popups - **Dialog** — Centered modal requiring user focus. `./references/primitives/dialog.md` - **AlertDialog** — Destructive/critical confirmation modal. `./references/primitives/alert-dialog.md` diff --git a/apps/ui/skills/coss/references/primitives/radio-group.md b/apps/ui/skills/coss/references/primitives/radio-group.md index d7ff91391..001a06158 100644 --- a/apps/ui/skills/coss/references/primitives/radio-group.md +++ b/apps/ui/skills/coss/references/primitives/radio-group.md @@ -10,6 +10,7 @@ - If multiple options can be selected -> use CheckboxGroup instead. - If options are many and need search/filtering -> use Select or Combobox instead. - If the choices are binary (on/off) -> use Switch or Checkbox instead. +- If each option reveals a content panel rather than selecting a value -> use Tabs instead. ## Install @@ -85,7 +86,7 @@ const [value, setValue] = useState("default") ### More examples -See `p-radio-group-1` through `p-radio-group-5` for disabled, description, card-style, and form integration patterns. +See `p-radio-group-1` through `p-radio-group-5` for disabled, description, card-style, and form integration patterns. For a segmented presentation, use `p-radio-group-7`, `p-radio-group-8`, or `p-radio-group-9`; read `../segmented-control.md` before choosing the underlying primitive. ## Common pitfalls @@ -99,4 +100,5 @@ See `p-radio-group-1` through `p-radio-group-5` for disabled, description, card- - with description: `p-radio-group-3` - card style: `p-radio-group-4` - form integration: `p-radio-group-5` +- segmented, small/default/large: `p-radio-group-7`, `p-radio-group-8`, `p-radio-group-9` - form composition references: `p-form-1`, `p-form-2`, `p-input-group-24` diff --git a/apps/ui/skills/coss/references/primitives/tabs.md b/apps/ui/skills/coss/references/primitives/tabs.md index 769e700e1..350a852c7 100644 --- a/apps/ui/skills/coss/references/primitives/tabs.md +++ b/apps/ui/skills/coss/references/primitives/tabs.md @@ -14,9 +14,11 @@ npx shadcn@latest add @coss/tabs Manual deps from docs: ```bash -# No extra runtime dependency required for this primitive. +npm install @base-ui/react class-variance-authority ``` +The CLI installs the shared `@coss/segmented-control` registry dependency automatically. For a manual installation, also copy `lib/segmented-control.ts` before copying the Tabs component. + ## Canonical imports ```tsx @@ -57,11 +59,20 @@ const [value, setValue] = useState("tab-1") ``` +Sizes are set on `TabsList` and inherited by its tabs. An individual `TabsTab` can override the inherited size. + +```tsx + + Tab 1 + Tab 2 + +``` + Underline variant: ```tsx - - + + Tab 1 Tab 2 @@ -79,8 +90,9 @@ Underline variant: - Mismatching `TabsTab value` and `TabsPanel value` pairs. - Using tabs for workflows that require route-level navigation instead. +- Using tabs for a form value or reversible filter just because it looks like a segmented control. Read `../segmented-control.md` and choose semantics first. - Mounting expensive panel content without considering visibility/performance. ## Useful particle references -See `p-tabs-1` through `p-tabs-4` for variants and orientations. Related: `p-toolbar-1`, `p-card-1`. +See `p-tabs-1` through `p-tabs-4` for variants and orientations, plus `p-tabs-14` and `p-tabs-15` for small and large segmented Tabs. Related: `p-toolbar-1`, `p-card-1`. diff --git a/apps/ui/skills/coss/references/primitives/toggle-group.md b/apps/ui/skills/coss/references/primitives/toggle-group.md index 45d6ff556..66212ad55 100644 --- a/apps/ui/skills/coss/references/primitives/toggle-group.md +++ b/apps/ui/skills/coss/references/primitives/toggle-group.md @@ -53,7 +53,7 @@ Toggle group with icon buttons: ``` -Multiple selection (default). For single selection use `type="single"`. +Toggle Group is exclusive by default and may be cleared. Add `multiple` only when more than one item may be pressed. Controlled toggle group: @@ -68,7 +68,7 @@ const [value, setValue] = useState(["bold"]) ### More examples -See `p-toggle-group-1` through `p-toggle-group-9` for sizes, outline, vertical, disabled, multiple, and tooltip patterns. +See `p-toggle-group-1` through `p-toggle-group-9` for sizes, outline, vertical, disabled, multiple, and tooltip patterns. Read `../segmented-control.md` before applying segmented styling to a custom reversible mode. ## Common pitfalls diff --git a/apps/ui/skills/coss/references/segmented-control.md b/apps/ui/skills/coss/references/segmented-control.md new file mode 100644 index 000000000..47a3cee3a --- /dev/null +++ b/apps/ui/skills/coss/references/segmented-control.md @@ -0,0 +1,60 @@ +# coss Segmented Control Pattern + +A segmented control is a shared visual treatment, not a standalone behavior. Choose the underlying primitive from the interaction before applying the styling. + +## Choose the behavior + +| Intent | Use | State model | +| --- | --- | --- | +| Choose one value, especially in a form | Radio Group | Exactly one mutually exclusive value | +| Navigate to another URL or route | Links | Browser navigation with `aria-current="page"` on the current link | +| Apply an exclusive filter or reversible mode | Toggle Group | At most one pressed item by default; the current item may be cleared | +| Switch between associated content panels | Tabs | Each tab controls a matching panel | + +Do not use Tabs for navigation links, form values, or filters merely because the result should look segmented. Do not use Toggle Group when one value must always remain selected; use Radio Group instead. + +## Install a particle + +| Implementation | Small | Default | Large | +| --- | --- | --- | --- | +| Radio Group | `@coss/p-radio-group-7` | `@coss/p-radio-group-8` | `@coss/p-radio-group-9` | +| Navigation links | `@coss/p-navigation-2` | `@coss/p-navigation-1` | `@coss/p-navigation-3` | + +The CLI installs the relevant primitive and the shared `@coss/segmented-control` styling library automatically. + +COSS does not publish a Toggle Group segmented particle. Use the shared styling directly only when a reversible mode genuinely allows no active option. + +## Custom composition + +Install the styling library directly when none of the particles matches: + +```bash +npx shadcn@latest add @coss/segmented-control +``` + +```tsx +import { + segmentedControlItemVariants, + segmentedControlRootClassName, +} from "@/lib/segmented-control" +``` + +Apply `segmentedControlRootClassName` to the group surface. Generate one item class with the matching state selector: + +```tsx +const itemClassName = segmentedControlItemVariants({ + size: "default", + state: "checked", +}) +``` + +- `state: "checked"` for Radio Group +- `state: "current"` for navigation links +- `state: "pressed"` for Toggle Group +- `size: "sm" | "default" | "lg"` + +Use the exported Radio Group primitives for a custom segmented radio presentation so the native radio indicator is not rendered. Keep a default or controlled value when the interface requires one option to remain selected. + +Tabs do not use the shared state recipe because they retain their animated indicator. They import the shared size map: set `size="sm" | "default" | "lg"` on `TabsList`, and use a `TabsTab` size only as an item-level override. + +Reuse the shared root and item recipes instead of copying their classes into a new segmented particle. This keeps Tabs and the radio, navigation, and toggle implementations optically aligned. diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 136eb35a0..c3ebe2260 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -1,10 +1,18 @@ "use client"; import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; +import { + type SegmentedControlSize, + segmentedControlItemSizeClassNames, +} from "@coss/ui/lib/segmented-control"; import { cn } from "@coss/ui/lib/utils"; -import type React from "react"; +import * as React from "react"; -export type TabsVariant = "default" | "underline"; +type TabsVariant = "default" | "underline"; +type TabsSize = SegmentedControlSize; + +const TabsListContext: React.Context = + React.createContext("default"); export function Tabs({ className, @@ -24,10 +32,12 @@ export function Tabs({ export function TabsList({ variant = "default", + size = "default", className, children, ...props }: TabsPrimitive.List.Props & { + size?: TabsSize; variant?: TabsVariant; }): React.ReactElement { return ( @@ -40,10 +50,13 @@ export function TabsList({ : "data-[orientation=vertical]:px-1 data-[orientation=horizontal]:py-1 *:data-[slot=tabs-tab]:hover:bg-accent", className, )} + data-size={size} data-slot="tabs-list" {...props} > - {children} + + {children} + @@ -86,4 +107,10 @@ export function TabsPanel({ ); } -export { TabsPrimitive, TabsTab as TabsTrigger, TabsPanel as TabsContent }; +export { + TabsPrimitive, + TabsTab as TabsTrigger, + TabsPanel as TabsContent, + type TabsSize, + type TabsVariant, +}; diff --git a/packages/ui/src/lib/segmented-control.ts b/packages/ui/src/lib/segmented-control.ts new file mode 100644 index 000000000..bcef88500 --- /dev/null +++ b/packages/ui/src/lib/segmented-control.ts @@ -0,0 +1,35 @@ +import { cva } from "class-variance-authority"; + +export type SegmentedControlSize = "default" | "lg" | "sm"; + +export const segmentedControlItemSizeClassNames: Record< + SegmentedControlSize, + string +> = { + default: "h-8.5 px-[calc(--spacing(2.5)-1px)] sm:h-7.5", + lg: "h-9.5 px-[calc(--spacing(3)-1px)] sm:h-8.5", + sm: "h-7.5 px-[calc(--spacing(2)-1px)] sm:h-6.5", +}; + +export const segmentedControlRootClassName = + "relative z-0 flex w-fit items-center justify-center gap-0.5 rounded-lg bg-muted p-0.5"; + +export const segmentedControlItemVariants = cva( + "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base text-muted-foreground/72 outline-2 outline-transparent transition-[outline-color] hover:bg-transparent hover:text-muted-foreground focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm", + { + defaultVariants: { + size: "default", + }, + variants: { + size: segmentedControlItemSizeClassNames, + state: { + checked: + "data-checked:bg-background data-checked:text-foreground data-checked:shadow-sm/5 dark:data-checked:bg-input", + current: + "aria-[current=page]:bg-background aria-[current=page]:text-foreground aria-[current=page]:shadow-sm/5 dark:aria-[current=page]:bg-input", + pressed: + "data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm/5 dark:data-pressed:bg-input", + }, + }, + }, +);