@ponti-studios/ui 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponti-studios/ui",
3
- "version": "0.8.0",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
5
  "description": "Shared UI component library for Ponti Studios",
6
6
  "license": "MIT",
@@ -1,11 +1,12 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import * as React from "react";
3
3
 
4
- import { AppNavigation } from "./app-navigation";
4
+ import { Button } from "../primitives/button";
5
+ import { Navigation } from "./app-navigation";
5
6
 
6
- const meta: Meta<typeof AppNavigation> = {
7
+ const meta: Meta<typeof Navigation> = {
7
8
  title: "Navigation/AppNavigation",
8
- component: AppNavigation,
9
+ component: Navigation,
9
10
  tags: ["autodocs"],
10
11
  parameters: {
11
12
  layout: "fullscreen",
@@ -18,7 +19,7 @@ type Story = StoryObj<typeof meta>;
18
19
  // Shared helpers
19
20
  // ---------------------------------------------------------------------------
20
21
 
21
- const defaultLinks: Array<{ href: string; label: string }> = [
22
+ const defaultLinks = [
22
23
  { href: "/features", label: "Features" },
23
24
  { href: "/pricing", label: "Pricing" },
24
25
  { href: "/blog", label: "Blog" },
@@ -45,12 +46,25 @@ function PageContent({
45
46
  export const Default: Story = {
46
47
  render: () => (
47
48
  <>
48
- <AppNavigation
49
- brand="Acme"
50
- brandHref="/"
51
- links={defaultLinks}
52
- cta={{ href: "/get-started", label: "Get started", variant: "default" }}
53
- />
49
+ <Navigation>
50
+ <Navigation.Brand>
51
+ <a href="/">Acme</a>
52
+ </Navigation.Brand>
53
+
54
+ <Navigation.List>
55
+ {defaultLinks.map((link) => (
56
+ <Navigation.Item key={link.href} href={link.href}>
57
+ {link.label}
58
+ </Navigation.Item>
59
+ ))}
60
+ </Navigation.List>
61
+
62
+ <Navigation.Action>
63
+ <Button size="md" asChild>
64
+ <a href="/get-started">Get started</a>
65
+ </Button>
66
+ </Navigation.Action>
67
+ </Navigation>
54
68
  <PageContent />
55
69
  </>
56
70
  ),
@@ -59,22 +73,99 @@ export const Default: Story = {
59
73
  export const NoCta: Story = {
60
74
  render: () => (
61
75
  <>
62
- <AppNavigation brand="Acme" brandHref="/" links={defaultLinks} />
63
- <PageContent message="Same nav, no CTA button." />
76
+ <Navigation>
77
+ <Navigation.Brand>
78
+ <a href="/">Acme</a>
79
+ </Navigation.Brand>
80
+ <Navigation.List>
81
+ {defaultLinks.map((link) => (
82
+ <Navigation.Item key={link.href} href={link.href}>
83
+ {link.label}
84
+ </Navigation.Item>
85
+ ))}
86
+ </Navigation.List>
87
+ </Navigation>
88
+ <PageContent message="Same nav, no Action content." />
64
89
  </>
65
90
  ),
66
91
  };
67
92
 
68
- export const OutlineCta: Story = {
93
+ /**
94
+ * `Navigation.Item` (and any of the other pieces) support the same
95
+ * `asChild` composition as `Button`: pass your router's `Link` (or
96
+ * anything else) as the single child. This story stands in for a router
97
+ * `Link` with a plain anchor that no-ops on click, since Storybook has no
98
+ * router.
99
+ */
100
+ export const WithCustomRouterLink: Story = {
101
+ render: () => {
102
+ const activeHref = "/pricing";
103
+ return (
104
+ <>
105
+ <Navigation>
106
+ <Navigation.Brand>
107
+ <a href="/" data-router-link="stand-in" onClick={(e) => e.preventDefault()}>
108
+ Acme
109
+ </a>
110
+ </Navigation.Brand>
111
+
112
+ <Navigation.List>
113
+ {defaultLinks.map((link) => (
114
+ <Navigation.Item key={link.href} asChild active={link.href === activeHref}>
115
+ <a href={link.href} data-router-link="stand-in" onClick={(e) => e.preventDefault()}>
116
+ {link.label}
117
+ </a>
118
+ </Navigation.Item>
119
+ ))}
120
+ </Navigation.List>
121
+
122
+ <Navigation.Action>
123
+ <Button size="md" asChild>
124
+ <a href="/get-started" data-router-link="stand-in" onClick={(e) => e.preventDefault()}>
125
+ Get started
126
+ </a>
127
+ </Button>
128
+ </Navigation.Action>
129
+ </Navigation>
130
+ <PageContent message="Every link here is a custom element composed via asChild - open the elements panel to see data-router-link on each one, and the /pricing item's data-active/aria-current." />
131
+ </>
132
+ );
133
+ },
134
+ };
135
+
136
+ /**
137
+ * Composition means arbitrary content can sit alongside the link list and
138
+ * action area without any prop-shape changes to `Navigation` itself.
139
+ */
140
+ export const WithExtraContent: Story = {
69
141
  render: () => (
70
142
  <>
71
- <AppNavigation
72
- brand="Acme"
73
- brandHref="/"
74
- links={defaultLinks}
75
- cta={{ href: "/get-started", label: "Get started", variant: "outline" }}
76
- />
77
- <PageContent message="The outline CTA keeps the same navigation geometry with a quieter emphasis." />
143
+ <Navigation>
144
+ <Navigation.Brand>
145
+ <a href="/">Acme</a>
146
+ </Navigation.Brand>
147
+
148
+ <Navigation.List>
149
+ {defaultLinks.slice(0, 2).map((link) => (
150
+ <Navigation.Item key={link.href} href={link.href}>
151
+ {link.label}
152
+ </Navigation.Item>
153
+ ))}
154
+ </Navigation.List>
155
+
156
+ <input
157
+ type="search"
158
+ placeholder="Search..."
159
+ className="h-9 w-40 rounded-md border bg-background px-3 text-sm"
160
+ />
161
+
162
+ <Navigation.Action>
163
+ <Button size="md" variant="outline" asChild>
164
+ <a href="/get-started">Get started</a>
165
+ </Button>
166
+ </Navigation.Action>
167
+ </Navigation>
168
+ <PageContent message="A search input sits directly between the link list and the action area — no endContent prop needed." />
78
169
  </>
79
170
  ),
80
171
  };
@@ -1,160 +1,182 @@
1
1
  import { Menu } from "lucide-react";
2
- import type { ComponentPropsWithoutRef } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import * as React from "react";
4
4
 
5
5
  import { cn } from "../../lib/utils";
6
+ import { Slot } from "../../lib/slot";
6
7
  import { Sheet, SheetClose, SheetContent, SheetTitle, SheetTrigger } from "../overlays/sheet";
7
8
  import { Button } from "../primitives/button";
8
9
 
9
- export interface AppNavigationLink {
10
- href: string;
11
- label: string;
10
+ /** True while rendering inside the mobile Sheet copy of the nav content. */
11
+ const MobileMenuContext = React.createContext(false);
12
+
13
+ export interface NavigationRootProps {
14
+ children: ReactNode;
15
+ ariaLabel?: string;
16
+ className?: string;
12
17
  }
13
18
 
14
- export interface AppNavigationCta {
15
- href: string;
16
- label: string;
17
- variant?: "default" | "outline";
19
+ /**
20
+ * Sticky nav shell. Compose it with `Navigation.Brand`, `Navigation.List`
21
+ * (containing `Navigation.Item`s), and any other content — a CTA `Button`,
22
+ * a search input, a notification bell — as plain children. `Navigation`
23
+ * only owns the responsive chrome: `Navigation.Brand` stays visible at
24
+ * every width, everything else collapses into a Sheet-based mobile menu
25
+ * below `sm`, reusing the exact elements you composed (not a parallel data
26
+ * shape) so `Navigation.Item`'s active state and `asChild` composition
27
+ * behave identically in both places.
28
+ */
29
+ function NavigationRoot({ children, ariaLabel = "Primary navigation", className }: NavigationRootProps) {
30
+ const [mobileOpen, setMobileOpen] = React.useState(false);
31
+
32
+ const items = React.Children.toArray(children);
33
+ const brand = items.find((child) => React.isValidElement(child) && child.type === NavigationBrand);
34
+ const rest = items.filter((child) => child !== brand);
35
+
36
+ return (
37
+ <header className="bg-background/95 supports-backdrop-filter:bg-background/60 sticky top-0 z-50 flex w-full justify-center border-b px-4 backdrop-blur">
38
+ <nav
39
+ className={cn("flex min-h-14 w-full max-w-7xl items-center justify-between gap-6", className)}
40
+ aria-label={ariaLabel}
41
+ >
42
+ <div className="flex min-w-0 items-center">{brand}</div>
43
+
44
+ <div className="ml-auto hidden min-w-0 items-center gap-6 sm:flex">{rest}</div>
45
+
46
+ <div className="ml-auto flex items-center sm:hidden">
47
+ <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
48
+ <SheetTrigger asChild>
49
+ <Button
50
+ variant="outline"
51
+ size="icon"
52
+ aria-label={mobileOpen ? "Close menu" : "Open menu"}
53
+ >
54
+ <Menu className="size-4" aria-hidden="true" />
55
+ </Button>
56
+ </SheetTrigger>
57
+ <SheetContent>
58
+ <SheetTitle className="sr-only">Navigation menu</SheetTitle>
59
+ <div className="mt-8 flex flex-1 flex-col gap-4">
60
+ <MobileMenuContext.Provider value={true}>{rest}</MobileMenuContext.Provider>
61
+ </div>
62
+ </SheetContent>
63
+ </Sheet>
64
+ </div>
65
+ </nav>
66
+ </header>
67
+ );
18
68
  }
19
69
 
20
- export interface AppNavigationProps {
21
- brand?: React.ReactNode;
22
- brandHref?: string;
23
- endContent?: React.ReactNode;
24
- links?: AppNavigationLink[];
25
- cta?: AppNavigationCta;
26
- ariaLabel?: string;
27
- /** Current pathname used to highlight the active link. */
28
- activeHref?: string;
70
+ export interface NavigationBrandProps {
71
+ children: ReactNode;
72
+ className?: string;
29
73
  }
30
74
 
31
- interface NavigationLinkOwnProps {
32
- active?: boolean;
33
- brand?: boolean;
75
+ /** Always visible, at every viewport width — compose your own link/logo markup directly. */
76
+ function NavigationBrand({ children, className }: NavigationBrandProps) {
77
+ return <div className={cn("flex items-center font-semibold", className)}>{children}</div>;
34
78
  }
35
79
 
36
- export type NavigationLinkProps = NavigationLinkOwnProps &
37
- Omit<ComponentPropsWithoutRef<"a">, keyof NavigationLinkOwnProps>;
80
+ export interface NavigationListProps {
81
+ children: ReactNode;
82
+ className?: string;
83
+ }
38
84
 
39
- function NavigationLink({
40
- children,
41
- active = false,
42
- brand = false,
43
- className,
44
- ...props
45
- }: NavigationLinkProps) {
85
+ /** Semantic `<ul>` of `Navigation.Item`s. Lays out horizontally on desktop, stacks inside the mobile Sheet. */
86
+ function NavigationList({ children, className }: NavigationListProps) {
87
+ const isMobile = React.useContext(MobileMenuContext);
46
88
  return (
47
- <a
48
- {...props}
89
+ <ul
49
90
  className={cn(
50
- "inline-flex h-9 max-h-9 shrink-0 items-center px-3 py-2 text-sm font-medium",
51
- !brand && "hover:bg-muted hover:text-muted-foreground",
91
+ "flex items-center gap-1",
92
+ isMobile && "flex-col items-stretch gap-1",
52
93
  className,
53
94
  )}
54
- aria-current={active ? "page" : undefined}
55
95
  >
56
96
  {children}
57
- </a>
97
+ </ul>
58
98
  );
59
99
  }
60
100
 
61
- function NavigationCta({ href, label }: AppNavigationCta) {
62
- return (
63
- <Button asChild size="md">
64
- <a href={href}>{label}</a>
65
- </Button>
66
- );
101
+ interface NavigationItemOwnProps {
102
+ active?: boolean;
103
+ /** Apply the item styling to a single child (e.g. your router's `Link`) instead of rendering an `<a>`. */
104
+ asChild?: boolean;
67
105
  }
68
106
 
69
- function NavigationRoot({
70
- brand,
71
- brandHref = "/",
72
- endContent,
73
- links,
74
- cta,
75
- ariaLabel = "Primary navigation",
76
- activeHref,
77
- }: AppNavigationProps) {
78
- const [mobileOpen, setMobileOpen] = React.useState(false);
107
+ export type NavigationItemProps = NavigationItemOwnProps &
108
+ Omit<ComponentPropsWithoutRef<"a">, keyof NavigationItemOwnProps>;
109
+
110
+ /** A single nav link, wrapped in `<li>`. Auto-closes the mobile Sheet on click when rendered there. */
111
+ function NavigationItem({ active = false, asChild = false, className, children, ...props }: NavigationItemProps) {
112
+ const isMobile = React.useContext(MobileMenuContext);
113
+ const itemClassName = cn(
114
+ "inline-flex h-9 max-h-9 shrink-0 items-center px-3 py-2 text-sm font-medium hover:bg-muted hover:text-muted-foreground data-[active]:font-semibold data-[active]:text-foreground",
115
+ className,
116
+ );
79
117
 
80
- const isActive = (href: string) => {
81
- if (!activeHref) return false;
82
- if (href === activeHref) return true;
83
- // Nested routes (e.g. /work/123) keep the parent link active.
84
- return href !== "/" && activeHref.startsWith(`${href}/`);
85
- };
118
+ const content = asChild ? (
119
+ <Slot
120
+ {...props}
121
+ data-active={active || undefined}
122
+ aria-current={active ? "page" : undefined}
123
+ className={itemClassName}
124
+ >
125
+ {children}
126
+ </Slot>
127
+ ) : (
128
+ <a
129
+ {...props}
130
+ data-active={active || undefined}
131
+ aria-current={active ? "page" : undefined}
132
+ className={itemClassName}
133
+ >
134
+ {children}
135
+ </a>
136
+ );
137
+
138
+ return <li>{isMobile ? <SheetClose asChild>{content}</SheetClose> : content}</li>;
139
+ }
140
+
141
+ export interface NavigationActionProps {
142
+ children: ReactNode;
143
+ className?: string;
144
+ }
86
145
 
146
+ /**
147
+ * Layout wrapper for a CTA, avatar menu, theme toggle, etc. — bring your
148
+ * own `Button`, no dedicated CTA component needed. Content here does not
149
+ * auto-close the mobile Sheet on click (it may hold more than one
150
+ * element, or non-navigational controls) — use `Navigation.Item` instead
151
+ * for a link that should close the menu when tapped.
152
+ */
153
+ function NavigationAction({ children, className }: NavigationActionProps) {
154
+ const isMobile = React.useContext(MobileMenuContext);
87
155
  return (
88
- <header className="bg-background/95 supports-backdrop-filter:bg-background/60 sticky top-0 z-50 flex w-full justify-center border-b px-4 backdrop-blur">
89
- <nav className="w-full max-w-7xl" aria-label={ariaLabel}>
90
- <div className="flex min-h-14 items-center justify-between gap-6">
91
- <div className="flex min-w-0 items-center">
92
- {brand && (
93
- <NavigationLink href={brandHref} brand>
94
- {brand}
95
- </NavigationLink>
96
- )}
97
- </div>
98
-
99
- <div className="ml-auto hidden min-w-0 items-center gap-1 sm:flex">
100
- {links?.map((link) => (
101
- <NavigationLink key={link.href} href={link.href} active={isActive(link.href)}>
102
- {link.label}
103
- </NavigationLink>
104
- ))}
105
- {cta && <NavigationCta {...cta} />}
106
- {endContent}
107
- </div>
108
-
109
- <div className="ml-auto flex items-center sm:hidden">
110
- <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
111
- <SheetTrigger asChild>
112
- <Button
113
- variant="outline"
114
- size="icon"
115
- aria-label={mobileOpen ? "Close menu" : "Open menu"}
116
- >
117
- <Menu className="size-4" aria-hidden="true" />
118
- </Button>
119
- </SheetTrigger>
120
- <SheetContent>
121
- <SheetTitle className="sr-only">Navigation menu</SheetTitle>
122
-
123
- <div className="mt-8 flex flex-1 flex-col gap-1">
124
- {links?.map((link) => (
125
- <SheetClose asChild key={link.href}>
126
- <NavigationLink href={link.href} active={isActive(link.href)}>
127
- {link.label}
128
- </NavigationLink>
129
- </SheetClose>
130
- ))}
131
- </div>
132
-
133
- {(cta || endContent) && (
134
- <div className="flex flex-col gap-4 border-t pt-4">
135
- {cta && (
136
- <SheetClose asChild>
137
- <NavigationCta {...cta} />
138
- </SheetClose>
139
- )}
140
- {endContent && <div className="flex items-center gap-3">{endContent}</div>}
141
- </div>
142
- )}
143
- </SheetContent>
144
- </Sheet>
145
- </div>
146
- </div>
147
- </nav>
148
- </header>
156
+ <div
157
+ className={cn(
158
+ "flex items-center gap-3",
159
+ isMobile && "mt-2 flex-col items-stretch gap-3 border-t pt-4",
160
+ className,
161
+ )}
162
+ >
163
+ {children}
164
+ </div>
149
165
  );
150
166
  }
151
167
 
152
168
  type NavigationComponent = typeof NavigationRoot & {
153
- Link: typeof NavigationLink;
169
+ Brand: typeof NavigationBrand;
170
+ List: typeof NavigationList;
171
+ Item: typeof NavigationItem;
172
+ Action: typeof NavigationAction;
154
173
  };
155
174
 
156
175
  export const Navigation: NavigationComponent = Object.assign(NavigationRoot, {
157
- Link: NavigationLink,
176
+ Brand: NavigationBrand,
177
+ List: NavigationList,
178
+ Item: NavigationItem,
179
+ Action: NavigationAction,
158
180
  });
159
181
 
160
182
  /** @deprecated Use `Navigation` instead. */
@@ -1,9 +1,10 @@
1
1
  export { AppNavigation, Navigation } from "./app-navigation";
2
2
  export type {
3
- AppNavigationCta,
4
- AppNavigationLink,
5
- AppNavigationProps,
6
- NavigationLinkProps,
3
+ NavigationActionProps,
4
+ NavigationBrandProps,
5
+ NavigationItemProps,
6
+ NavigationListProps,
7
+ NavigationRootProps,
7
8
  } from "./app-navigation";
8
9
  export { PaginationControls } from "./pagination-controls";
9
10
  export type { PaginationControlsProps } from "./pagination-controls";
@@ -0,0 +1,29 @@
1
+ import { mergeProps } from "@base-ui/react/merge-props";
2
+ import type { ReactElement } from "react";
3
+ import * as React from "react";
4
+
5
+ export interface SlotProps extends React.HTMLAttributes<HTMLElement> {
6
+ children?: React.ReactNode;
7
+ }
8
+
9
+ /**
10
+ * Generic `asChild` primitive: merges the props it receives onto its single
11
+ * child instead of rendering its own DOM element. Lets compound components
12
+ * (e.g. `Navigation.Item`) apply base styling/aria wiring to whatever the
13
+ * app renders — a router `Link`, a plain `<a>`, anything — without the app
14
+ * passing a component as a prop; it's composed as JSX children instead.
15
+ *
16
+ * Uses `mergeProps` (already a dependency via `@base-ui/react`) so
17
+ * event handlers chain instead of one overwriting the other, and
18
+ * `className`/`style` merge instead of replacing.
19
+ */
20
+ export const Slot = React.forwardRef<HTMLElement, SlotProps>(({ children, ...props }, ref) => {
21
+ if (!React.isValidElement(children)) return null;
22
+ const child = children as ReactElement<Record<string, unknown>>;
23
+ const merged = mergeProps<"div">(
24
+ props as React.ComponentPropsWithRef<"div">,
25
+ child.props as React.ComponentPropsWithRef<"div">,
26
+ );
27
+ return React.cloneElement(child, { ...merged, ref } as Record<string, unknown>);
28
+ });
29
+ Slot.displayName = "Slot";