Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 23 additions & 16 deletions apps/ui/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
AnchoredToastProvider,
ToastProvider,
} from "@/registry/default/ui/toast";
import { SiteDirectionProvider } from "@/components/direction-switcher";
import { SiteHeader } from "@/components/site-header";
import { directionScript } from "@/lib/direction";

export const metadata: Metadata = {
description:
Expand All @@ -24,26 +26,31 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: directionScript }} />
</head>
<body
className={`${fontSans.variable} ${fontMono.variable} relative bg-sidebar font-sans text-foreground antialiased`}
>
<ThemeProvider>
<ToastProvider>
<AnchoredToastProvider>
<div className="relative isolate flex min-h-svh flex-col overflow-clip [--header-height:4rem]">
<div
aria-hidden="true"
className="container pointer-events-none absolute inset-0 z-45 before:absolute before:inset-y-0 before:-left-3 before:w-px before:bg-border/64 after:absolute after:inset-y-0 after:-right-3 after:w-px after:bg-border/64"
/>
<div
aria-hidden="true"
className="container pointer-events-none fixed inset-x-0 top-0 z-45 h-(--header-height) before:absolute before:top-[calc(var(--header-height)-4.5px)] before:-left-[11.5px] before:z-1 before:-ml-1 before:size-2 before:rounded-[2px] before:border before:border-border before:bg-popover before:bg-clip-padding before:shadow-xs/5 after:absolute after:top-[calc(var(--header-height)-4.5px)] after:-right-[11.5px] after:z-1 after:-mr-1 after:size-2 after:rounded-[2px] after:border after:border-border after:bg-background after:bg-clip-padding after:shadow-xs/5 dark:after:bg-clip-border dark:before:bg-clip-border"
/>
<SiteHeader />
{children}
</div>
</AnchoredToastProvider>
</ToastProvider>
<SiteDirectionProvider>
<ToastProvider>
<AnchoredToastProvider>
<div className="relative isolate flex min-h-svh flex-col overflow-clip [--header-height:4rem]">
<div
aria-hidden="true"
className="container pointer-events-none absolute inset-0 z-45 before:absolute before:inset-y-0 before:-left-3 before:w-px before:bg-border/64 after:absolute after:inset-y-0 after:-right-3 after:w-px after:bg-border/64"
/>
<div
aria-hidden="true"
className="container pointer-events-none fixed inset-x-0 top-0 z-45 h-(--header-height) before:absolute before:top-[calc(var(--header-height)-4.5px)] before:-left-[11.5px] before:z-1 before:-ml-1 before:size-2 before:rounded-[2px] before:border before:border-border before:bg-popover before:bg-clip-padding before:shadow-xs/5 after:absolute after:top-[calc(var(--header-height)-4.5px)] after:-right-[11.5px] after:z-1 after:-mr-1 after:size-2 after:rounded-[2px] after:border after:border-border after:bg-background after:bg-clip-padding after:shadow-xs/5 dark:after:bg-clip-border dark:before:bg-clip-border"
/>
<SiteHeader />
{children}
</div>
</AnchoredToastProvider>
</ToastProvider>
</SiteDirectionProvider>
</ThemeProvider>
</body>
</html>
Expand Down
75 changes: 75 additions & 0 deletions apps/ui/components/direction-switcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import { PilcrowLeftIcon, PilcrowRightIcon } from "lucide-react";
import { type ReactNode, useSyncExternalStore } from "react";
import { DirectionProvider } from "@/registry/default/base-ui/direction-provider";
import { Button } from "@/registry/default/ui/button";
import { DIRECTION_STORAGE_KEY } from "@/lib/direction";

type Direction = "ltr" | "rtl";

const listeners = new Set<() => void>();

function applyDirection(direction: Direction) {
document.documentElement.dir = direction;
for (const listener of listeners) listener();
}

function onStorage(event: StorageEvent) {
if (event.key !== DIRECTION_STORAGE_KEY) return;
applyDirection(event.newValue === "rtl" ? "rtl" : "ltr");
}

function subscribe(listener: () => void) {
if (listeners.size === 0) window.addEventListener("storage", onStorage);
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) window.removeEventListener("storage", onStorage);
};
}

function getDirection(): Direction {
return document.documentElement.dir === "rtl" ? "rtl" : "ltr";
}

function getServerDirection(): Direction {
return "ltr";
}

function setDirection(direction: Direction) {
try {
localStorage.setItem(DIRECTION_STORAGE_KEY, direction);
} catch {}
applyDirection(direction);
}

function useSiteDirection() {
return useSyncExternalStore(subscribe, getDirection, getServerDirection);
}

export function SiteDirectionProvider({ children }: { children: ReactNode }) {
const direction = useSiteDirection();

return (
<DirectionProvider direction={direction}>{children}</DirectionProvider>
);
}

export function DirectionSwitcher() {
const isRtl = useSiteDirection() === "rtl";

return (
<Button
aria-label="Toggle direction"
className="size-8"
onClick={() => setDirection(isRtl ? "ltr" : "rtl")}
size="icon"
title="Toggle direction"
variant="ghost"
>
<PilcrowRightIcon aria-hidden="true" className="size-4 rtl:hidden" />
<PilcrowLeftIcon aria-hidden="true" className="hidden size-4 rtl:block" />
</Button>
);
}
2 changes: 2 additions & 0 deletions apps/ui/components/site-header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Separator } from "@coss/ui/components/separator";
import { SiteHeader as WorkspaceSiteHeader } from "@coss/ui/shared/site-header";
import { CommandMenu } from "@/components/command-menu";
import { DirectionSwitcher } from "@/components/direction-switcher";
import { MainNav } from "@/components/main-nav";
import { MobileNav } from "@/components/mobile-nav";
import { appConfig } from "@/lib/config";
Expand All @@ -11,6 +12,7 @@ export function SiteHeader() {

return (
<WorkspaceSiteHeader
actions={<DirectionSwitcher />}
currentProduct="ui"
mobileNav={
<MobileNav
Expand Down
4 changes: 4 additions & 0 deletions apps/ui/lib/direction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const DIRECTION_STORAGE_KEY = "direction";

// Runs before paint so a saved RTL choice doesn't flash LTR on reload.
export const directionScript = `try{if(localStorage.getItem("${DIRECTION_STORAGE_KEY}")==="rtl")document.documentElement.dir="rtl"}catch{}`;
2 changes: 1 addition & 1 deletion apps/ui/public/r/segmented-control.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/ui/public/r/tabs.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"files": [
{
"path": "registry/default/ui/tabs.tsx",
"content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport * as React from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport {\n type SegmentedControlSize,\n segmentedControlIndicatorSurfaceClassName,\n segmentedControlItemLayoutClassName,\n segmentedControlItemSizeClassNames,\n} from \"@/registry/default/ui/segmented-control\";\n\ntype TabsVariant = \"default\" | \"underline\";\ntype TabsSize = SegmentedControlSize;\n\nconst TabsListContext: React.Context<TabsSize> =\n React.createContext<TabsSize>(\"default\");\n\nexport function Tabs({\n className,\n ...props\n}: TabsPrimitive.Root.Props): React.ReactElement {\n return (\n <TabsPrimitive.Root\n className={cn(\n \"flex flex-col gap-2 data-[orientation=vertical]:flex-row\",\n className,\n )}\n data-slot=\"tabs\"\n {...props}\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 <TabsPrimitive.List\n className={cn(\n \"relative z-0 flex w-fit items-center justify-center gap-x-0.5 text-muted-foreground\",\n \"data-[orientation=vertical]:flex-col\",\n variant === \"default\"\n ? \"rounded-lg bg-muted p-0.5 text-muted-foreground/72\"\n : \"data-[orientation=vertical]:px-1 data-[orientation=horizontal]:py-1 *:data-[slot=tabs-tab]:hover:bg-accent\",\n className,\n )}\n data-size={size}\n data-slot=\"tabs-list\"\n {...props}\n >\n <TabsListContext.Provider value={size}>\n {children}\n </TabsListContext.Provider>\n <TabsPrimitive.Indicator\n className={cn(\n \"absolute bottom-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) -translate-y-(--active-tab-bottom) transition-[width,translate] duration-200 ease-in-out motion-reduce:transition-none\",\n variant === \"underline\"\n ? \"z-10 bg-primary data-[orientation=horizontal]:h-0.5 data-[orientation=vertical]:w-0.5 data-[orientation=vertical]:-translate-x-px data-[orientation=horizontal]:translate-y-px\"\n : cn(\"-z-1\", segmentedControlIndicatorSurfaceClassName),\n )}\n data-slot=\"tab-indicator\"\n />\n </TabsPrimitive.List>\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 <TabsPrimitive.Tab\n className={cn(\n \"relative flex shrink-0 grow cursor-pointer items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base outline-none transition-[color,background-color,box-shadow] hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring data-disabled:pointer-events-none data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start data-active:text-foreground data-disabled:opacity-64 sm:text-sm\",\n segmentedControlItemLayoutClassName,\n segmentedControlItemSizeClassNames[resolvedSize],\n className,\n )}\n data-size={resolvedSize}\n data-slot=\"tabs-tab\"\n {...props}\n />\n );\n}\n\nexport function TabsPanel({\n className,\n ...props\n}: TabsPrimitive.Panel.Props): React.ReactElement {\n return (\n <TabsPrimitive.Panel\n className={cn(\"flex-1 outline-none\", className)}\n data-slot=\"tabs-content\"\n {...props}\n />\n );\n}\n\nexport {\n TabsPrimitive,\n TabsTab as TabsTrigger,\n TabsPanel as TabsContent,\n type TabsSize,\n type TabsVariant,\n};\n",
"content": "\"use client\";\n\nimport { useDirection } from \"@base-ui/react/direction-provider\";\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport * as React from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport {\n type SegmentedControlSize,\n segmentedControlIndicatorSurfaceClassName,\n segmentedControlItemLayoutClassName,\n segmentedControlItemSizeClassNames,\n} from \"@/registry/default/ui/segmented-control\";\n\ntype TabsVariant = \"default\" | \"underline\";\ntype TabsSize = SegmentedControlSize;\n\nconst TabsListContext: React.Context<TabsSize> =\n React.createContext<TabsSize>(\"default\");\n\nexport function Tabs({\n className,\n ...props\n}: TabsPrimitive.Root.Props): React.ReactElement {\n return (\n <TabsPrimitive.Root\n className={cn(\n \"flex flex-col gap-2 data-[orientation=vertical]:flex-row\",\n className,\n )}\n data-slot=\"tabs\"\n {...props}\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 const direction = useDirection();\n\n return (\n <TabsPrimitive.List\n className={cn(\n \"relative z-0 flex w-fit items-center justify-center gap-x-0.5 text-muted-foreground\",\n \"data-[orientation=vertical]:flex-col\",\n variant === \"default\"\n ? \"rounded-lg bg-muted p-0.5 text-muted-foreground/72\"\n : \"data-[orientation=vertical]:px-1 data-[orientation=horizontal]:py-1 *:data-[slot=tabs-tab]:hover:bg-accent\",\n className,\n )}\n data-size={size}\n data-slot=\"tabs-list\"\n {...props}\n >\n <TabsListContext.Provider value={size}>\n {children}\n </TabsListContext.Provider>\n <TabsPrimitive.Indicator\n className={cn(\n \"absolute bottom-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) -translate-y-(--active-tab-bottom) transition-[width,translate] duration-200 ease-in-out motion-reduce:transition-none\",\n variant === \"underline\"\n ? \"z-10 bg-primary data-[orientation=horizontal]:h-0.5 data-[orientation=vertical]:w-0.5 data-[orientation=vertical]:-translate-x-px data-[orientation=horizontal]:translate-y-px\"\n : cn(\"-z-1\", segmentedControlIndicatorSurfaceClassName),\n )}\n data-slot=\"tab-indicator\"\n key={direction}\n />\n </TabsPrimitive.List>\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 <TabsPrimitive.Tab\n className={cn(\n \"relative flex shrink-0 grow cursor-pointer items-center justify-center whitespace-nowrap rounded-md border border-transparent font-medium text-base outline-none transition-[color,background-color,box-shadow] hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring data-disabled:pointer-events-none data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start data-active:text-foreground data-disabled:opacity-64 sm:text-sm\",\n segmentedControlItemLayoutClassName,\n segmentedControlItemSizeClassNames[resolvedSize],\n className,\n )}\n data-size={resolvedSize}\n data-slot=\"tabs-tab\"\n {...props}\n />\n );\n}\n\nexport function TabsPanel({\n className,\n ...props\n}: TabsPrimitive.Panel.Props): React.ReactElement {\n return (\n <TabsPrimitive.Panel\n className={cn(\"flex-1 outline-none\", className)}\n data-slot=\"tabs-content\"\n {...props}\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"
}
],
Expand Down
5 changes: 4 additions & 1 deletion apps/ui/registry/default/ui/segmented-control.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useDirection } from "@base-ui/react/direction-provider";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import * as React from "react";
Expand Down Expand Up @@ -34,11 +35,13 @@ export function SegmentedControl({
}: useRender.ComponentProps<"div"> & {
size?: SegmentedControlSize;
}): React.ReactElement {
const direction = useDirection();

const defaultProps = {
children: (
<>
{children}
<SegmentedControlIndicator />
<SegmentedControlIndicator key={direction} />
</>
),
className: cn(
Expand Down
4 changes: 4 additions & 0 deletions apps/ui/registry/default/ui/tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useDirection } from "@base-ui/react/direction-provider";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import * as React from "react";
import { cn } from "@/registry/default/lib/utils";
Expand Down Expand Up @@ -42,6 +43,8 @@ export function TabsList({
size?: TabsSize;
variant?: TabsVariant;
}): React.ReactElement {
const direction = useDirection();

return (
<TabsPrimitive.List
className={cn(
Expand All @@ -67,6 +70,7 @@ export function TabsList({
: cn("-z-1", segmentedControlIndicatorSurfaceClassName),
)}
data-slot="tab-indicator"
key={direction}
/>
</TabsPrimitive.List>
);
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/components/segmented-control.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useDirection } from "@base-ui/react/direction-provider";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@coss/ui/lib/utils";
Expand Down Expand Up @@ -34,11 +35,13 @@ export function SegmentedControl({
}: useRender.ComponentProps<"div"> & {
size?: SegmentedControlSize;
}): React.ReactElement {
const direction = useDirection();

const defaultProps = {
children: (
<>
{children}
<SegmentedControlIndicator />
<SegmentedControlIndicator key={direction} />
</>
),
className: cn(
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useDirection } from "@base-ui/react/direction-provider";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import {
type SegmentedControlSize,
Expand Down Expand Up @@ -42,6 +43,8 @@ export function TabsList({
size?: TabsSize;
variant?: TabsVariant;
}): React.ReactElement {
const direction = useDirection();

return (
<TabsPrimitive.List
className={cn(
Expand All @@ -67,6 +70,7 @@ export function TabsList({
: cn("-z-1", segmentedControlIndicatorSurfaceClassName),
)}
data-slot="tab-indicator"
key={direction}
/>
</TabsPrimitive.List>
);
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/shared/site-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import Link from "next/link";
export function SiteHeader({
mobileNav,
children,
actions,
currentProduct,
github,
}: {
mobileNav?: React.ReactNode;
children?: React.ReactNode;
actions?: React.ReactNode;
currentProduct?: string;
/** The product's own repository for the GitHub link and its stars. */
github?: { repo: string; href: string };
Expand Down Expand Up @@ -42,6 +44,7 @@ export function SiteHeader({
<div className="ms-auto flex items-center gap-2 md:flex-1 md:justify-end">
{children}
<GitHubLink {...github} />
{actions}
<ModeSwitcher />
</div>
</div>
Expand Down
Loading