Skip to content
Merged
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
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@ bun add @lumyx/ui
bun add github:lumyx-dev/ui
```

Peer dependencies: `react@^19`, `react-dom@^19`, `next@^16` (a few components use `next/link`
and `next/navigation`).
Peer dependencies: `react@^19` and `react-dom@^19`. The package does not depend on any
framework: links go through `LumyxLinkProvider` (see [Routing](#routing)).

## Setup (Next.js + Tailwind CSS v4)
## Setup (Tailwind CSS v4)

The package ships TypeScript source, not a build. Let Next.js compile it:
The package ships TypeScript source, not a build. With Next.js, let it compile the package:

```ts
// next.config.ts
const nextConfig = { transpilePackages: ['@lumyx/ui'] };
export default nextConfig;
```

Vite-based frameworks (Astro with `@astrojs/react`, Remix, …) compile it out of the box.

Import the tokens and point Tailwind at the component sources so their classes are generated:

```css
Expand All @@ -54,11 +56,35 @@ export function Example() {
}
```

## Routing

`SidebarNav`, `AppHeader` breadcrumbs and `RoomCard` render internal links. By default they are
plain `<a>` elements and no navigation item is marked active. Plug in your router once, in a
client component that wraps your pages:

```tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LumyxLinkProvider } from '@lumyx/ui';

export function Chrome({ children }: { children: React.ReactNode }) {
return (
<LumyxLinkProvider link={Link} pathname={usePathname()}>
{children}
</LumyxLinkProvider>
);
}
```

Outside a router (Astro islands, static pages), skip the provider, or pass `activeHref` to
`SidebarNav` directly.

Entry points:

| Import | Contents |
|---|---|
| `@lumyx/ui` | Components (`components/ui`, `components/lumyx`) and the `cn` helper |
| `@lumyx/ui` | Components (`components/ui`, `components/lumyx`), `LumyxLinkProvider` and the `cn` helper |
| `@lumyx/ui/styles.css` | Design tokens, the shadcn theme contract and base styles |
| `@lumyx/ui/code-theme` | Shiki code-highlighting theme matching the tokens |

Expand Down
102 changes: 0 additions & 102 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lumyx/ui",
"version": "0.1.0",
"version": "0.2.0",
"description": "The Lumyx design system: React components and Tailwind CSS v4 tokens.",
"license": "MIT",
"author": "Mathieu Chambaud",
Expand Down Expand Up @@ -35,6 +35,7 @@
"files": [
"src",
"!src/**/*.test.ts",
"!src/**/*.test.tsx",
"README.md",
"LICENSE"
],
Expand Down Expand Up @@ -64,7 +65,6 @@
"tailwind-merge": "^3.0.2"
},
"peerDependencies": {
"next": "^16",
"react": "^19",
"react-dom": "^19"
},
Expand All @@ -73,7 +73,6 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5",
"next": "16.3.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
Expand Down
3 changes: 2 additions & 1 deletion src/components/lumyx/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useLumyxLink } from "../../lib/link";
import { cn } from "../../lib/utils";
import { SidebarTrigger } from "../ui/sidebar";

Expand All @@ -23,6 +23,7 @@ export function AppHeader({
actions?: React.ReactNode;
className?: string;
}) {
const { Link } = useLumyxLink();
return (
<header className={cn("row-start-1 border-b border-hairline bg-card", className)}>
<div className="mx-auto flex w-full max-w-[1360px] items-center justify-between gap-6 px-4 py-4 lg:px-8">
Expand Down
4 changes: 3 additions & 1 deletion src/components/lumyx/room-card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from "next/link";
"use client";
import { useLumyxLink } from "../../lib/link";
import { cn } from '../../lib/utils';
import { StatusDot, type Status } from "./status-dot";
import { Badge } from '../ui/badge';
Expand All @@ -14,6 +15,7 @@ export type Room = {
};

export function RoomCard({ room, href, className }: { room: Room; href?: string; className?: string }) {
const { Link } = useLumyxLink();
const body = (
<div className={cn("rounded-lg border border-hairline bg-card p-5 shadow-[var(--shadow-sm)] transition-shadow duration-[180ms] hover:shadow-[var(--shadow-md)]", className)}>
<div className="flex items-start justify-between gap-3">
Expand Down
17 changes: 13 additions & 4 deletions src/components/lumyx/sidebar-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLumyxLink } from "../../lib/link";
import { cn } from "../../lib/utils";
import {
SidebarGroup,
Expand All @@ -18,8 +17,18 @@ export type NavSection = {
};

/** La nav de la sidebar : groupes, item actif, identite visuelle Lumyx. */
export function SidebarNav({ sections, ariaLabel }: { sections: NavSection[]; ariaLabel: string }) {
const pathname = usePathname();
export function SidebarNav({
sections,
ariaLabel,
activeHref,
}: {
sections: NavSection[];
ariaLabel: string;
/** Force l'item actif ; par defaut, le `pathname` du `LumyxLinkProvider`. */
activeHref?: string;
}) {
const { Link, pathname: contextPathname } = useLumyxLink();
const pathname = activeHref ?? contextPathname;
return (
<nav aria-label={ariaLabel} className="flex flex-col gap-5">
{sections.map((section) => (
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './lib/utils';
export * from './lib/link';
export * from './code-theme';

export * from './components/ui/badge';
Expand Down
64 changes: 64 additions & 0 deletions src/lib/link.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { LumyxLinkProvider } from "./link";
import { RoomCard } from "../components/lumyx/room-card";
import { SidebarNav } from "../components/lumyx/sidebar-nav";
import { SidebarProvider } from "../components/ui/sidebar";

const room = {
id: "standup",
region: "eu",
peers: 3,
status: "live" as const,
durationLabel: "12 min",
bitrateLabel: "1.2 Mb/s",
worstLossPct: 0.4,
};

function TestLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) {
return <a data-router="test" {...props} />;
}

const Icon = () => <svg />;
const sections = [{ label: "Live", items: [
{ href: "/", label: "Overview", icon: Icon },
{ href: "/rooms", label: "Rooms", icon: Icon },
] }];

const nav = (provider: { link?: typeof TestLink; pathname?: string }, activeHref?: string) =>
renderToStaticMarkup(
<LumyxLinkProvider {...provider}>
<SidebarProvider>
<SidebarNav sections={sections} ariaLabel="Main" activeHref={activeHref} />
</SidebarProvider>
</LumyxLinkProvider>,
);

describe("LumyxLinkProvider", () => {
test("without a provider, links are plain anchors", () => {
const html = renderToStaticMarkup(<RoomCard room={room} href="/rooms/standup" />);
expect(html).toContain('href="/rooms/standup"');
expect(html).not.toContain("data-router");
});

test("the provided link component renders every internal link", () => {
const html = renderToStaticMarkup(
<LumyxLinkProvider link={TestLink}>
<RoomCard room={room} href="/rooms/standup" />
</LumyxLinkProvider>,
);
expect(html).toContain('data-router="test"');
});

test("the provider pathname marks the active nav item", () => {
const html = nav({ link: TestLink, pathname: "/rooms" });
expect(html).toMatch(/aria-current="page"[^>]*href="\/rooms"|href="\/rooms"[^>]*aria-current="page"/);
expect(html.match(/aria-current="page"/g)).toHaveLength(1);
});

test("activeHref overrides the provider pathname", () => {
const html = nav({ pathname: "/rooms" }, "/");
expect(html).toMatch(/aria-current="page"[^>]*href="\/"|href="\/"[^>]*aria-current="page"/);
});
});
48 changes: 48 additions & 0 deletions src/lib/link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";
import * as React from "react";

/**
* Ce dont les composants Lumyx ont besoin d'un lien : les attributs d'une ancre et un `href`.
* `next/link`, le `Link` de React Router ou une simple balise `<a>` conviennent tous.
*/
export type LinkComponent = React.ComponentType<
React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }
>;

type LinkContextValue = {
/** Composant rendu pour chaque lien interne. Par defaut, une balise `<a>`. */
Link: LinkComponent;
/** Chemin courant, pour marquer l'item actif de la navigation. */
pathname?: string;
};

function Anchor(props: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) {
return <a {...props} />;
}

const LinkContext = React.createContext<LinkContextValue>({ Link: Anchor });

/**
* Branche le routeur de l'app sur les composants Lumyx, sans que le paquet depende d'un
* framework. A poser une fois, dans un composant client qui englobe les pages :
*
* <LumyxLinkProvider link={Link} pathname={usePathname()}>…</LumyxLinkProvider>
*
* Sans provider, les liens sont des `<a>` et aucun item de navigation n'est actif.
*/
export function LumyxLinkProvider({
link,
pathname,
children,
}: {
link?: LinkComponent;
pathname?: string;
children: React.ReactNode;
}) {
const value = React.useMemo(() => ({ Link: link ?? Anchor, pathname }), [link, pathname]);
return <LinkContext.Provider value={value}>{children}</LinkContext.Provider>;
}

export function useLumyxLink(): LinkContextValue {
return React.useContext(LinkContext);
}
Loading