@ponti-studios/ui 0.8.0 → 1.0.1
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 +1 -1
- package/src/components/data-display/sort-controls.tsx +8 -11
- package/src/components/data-display/sort-row.tsx +49 -35
- package/src/components/navigation/app-navigation.stories.tsx +111 -20
- package/src/components/navigation/app-navigation.tsx +142 -120
- package/src/components/navigation/index.ts +5 -4
- package/src/components/navigation/pagination-controls.tsx +1 -1
- package/src/lib/slot.tsx +29 -0
- package/src/styles/index.css +5 -3
package/package.json
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
DropdownMenu,
|
|
6
6
|
DropdownMenuContent,
|
|
7
7
|
DropdownMenuGroup,
|
|
8
|
-
DropdownMenuItem,
|
|
9
8
|
DropdownMenuLabel,
|
|
10
9
|
DropdownMenuSeparator,
|
|
11
10
|
DropdownMenuTrigger,
|
|
@@ -57,10 +56,9 @@ export function SortControls({
|
|
|
57
56
|
.filter((_, i) => i !== index)
|
|
58
57
|
.map((option) => option.field);
|
|
59
58
|
return (
|
|
60
|
-
<
|
|
59
|
+
<div
|
|
61
60
|
key={sort.field}
|
|
62
|
-
|
|
63
|
-
className={`p-0 focus:bg-transparent ${index === focusedSortIndex ? "bg-muted" : ""}`}
|
|
61
|
+
className={`rounded-sm ${index === focusedSortIndex ? "bg-muted" : ""}`}
|
|
64
62
|
>
|
|
65
63
|
<SortRow
|
|
66
64
|
sortOption={sort}
|
|
@@ -70,27 +68,26 @@ export function SortControls({
|
|
|
70
68
|
updateSortOption={updateSortOption}
|
|
71
69
|
removeSortOption={removeSortOption}
|
|
72
70
|
/>
|
|
73
|
-
</
|
|
71
|
+
</div>
|
|
74
72
|
);
|
|
75
73
|
})}
|
|
76
74
|
</DropdownMenuGroup>
|
|
77
75
|
{availableFieldsToAdd.length > 0 && (
|
|
78
76
|
<>
|
|
79
77
|
<DropdownMenuSeparator />
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={() => {
|
|
83
81
|
const firstAvailable = availableFieldsToAdd[0];
|
|
84
82
|
if (firstAvailable) {
|
|
85
83
|
addSortOption({ field: firstAvailable, direction: "desc" });
|
|
86
84
|
}
|
|
87
85
|
}}
|
|
88
|
-
|
|
89
|
-
className="mt-1"
|
|
86
|
+
className="hover:bg-muted focus-visible:ring-ring mt-1 flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
90
87
|
>
|
|
91
88
|
<PlusCircle className="mr-2 size-4" />
|
|
92
89
|
Add Sort Criterion
|
|
93
|
-
</
|
|
90
|
+
</button>
|
|
94
91
|
</>
|
|
95
92
|
)}
|
|
96
93
|
</DropdownMenuContent>
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { ArrowDownWideNarrow, ArrowUpWideNarrow, X } from "lucide-react";
|
|
2
|
+
|
|
1
3
|
import type { SortField, SortOption } from "../../hooks/sort.types";
|
|
2
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from "../overlays/dropdown-menu";
|
|
3
10
|
import { Button } from "../primitives/button";
|
|
4
|
-
import { X } from "lucide-react";
|
|
5
11
|
|
|
6
12
|
export interface SortRowProps {
|
|
7
13
|
sortOption: SortOption;
|
|
@@ -12,6 +18,10 @@ export interface SortRowProps {
|
|
|
12
18
|
removeSortOption: (index: number) => void;
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
function fieldLabel(field: SortField) {
|
|
22
|
+
return field.charAt(0).toUpperCase() + field.slice(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
export function SortRow({
|
|
16
26
|
sortOption,
|
|
17
27
|
index,
|
|
@@ -25,47 +35,51 @@ export function SortRow({
|
|
|
25
35
|
);
|
|
26
36
|
|
|
27
37
|
return (
|
|
28
|
-
<div
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
<div className="flex w-full items-center gap-1">
|
|
39
|
+
<DropdownMenu>
|
|
40
|
+
<DropdownMenuTrigger asChild>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
className="hover:bg-muted focus-visible:ring-ring flex-1 rounded-md px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
44
|
+
>
|
|
45
|
+
{fieldLabel(sortOption.field)}
|
|
46
|
+
</button>
|
|
47
|
+
</DropdownMenuTrigger>
|
|
48
|
+
<DropdownMenuContent>
|
|
37
49
|
{availableFieldsForCurrentSelect.map((field) => (
|
|
38
|
-
<
|
|
39
|
-
{field
|
|
40
|
-
|
|
50
|
+
<DropdownMenuItem
|
|
51
|
+
key={field}
|
|
52
|
+
onClick={() => updateSortOption(index, { ...sortOption, field })}
|
|
53
|
+
>
|
|
54
|
+
{fieldLabel(field)}
|
|
55
|
+
</DropdownMenuItem>
|
|
41
56
|
))}
|
|
42
|
-
</
|
|
43
|
-
</
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
</DropdownMenuContent>
|
|
58
|
+
</DropdownMenu>
|
|
59
|
+
<Button
|
|
60
|
+
variant="ghost"
|
|
61
|
+
size="sm"
|
|
62
|
+
className="size-8 p-0"
|
|
63
|
+
aria-label={sortOption.direction === "asc" ? "Sorted ascending" : "Sorted descending"}
|
|
64
|
+
onClick={() =>
|
|
65
|
+
updateSortOption(index, {
|
|
66
|
+
...sortOption,
|
|
67
|
+
direction: sortOption.direction === "asc" ? "desc" : "asc",
|
|
68
|
+
})
|
|
69
|
+
}
|
|
51
70
|
>
|
|
52
|
-
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
</SelectItem>
|
|
59
|
-
<SelectItem value="desc" className="text-xs">
|
|
60
|
-
Descending
|
|
61
|
-
</SelectItem>
|
|
62
|
-
</SelectContent>
|
|
63
|
-
</Select>
|
|
71
|
+
{sortOption.direction === "asc" ? (
|
|
72
|
+
<ArrowUpWideNarrow className="size-4" />
|
|
73
|
+
) : (
|
|
74
|
+
<ArrowDownWideNarrow className="size-4" />
|
|
75
|
+
)}
|
|
76
|
+
</Button>
|
|
64
77
|
<Button
|
|
65
78
|
variant="ghost"
|
|
66
79
|
size="sm"
|
|
67
80
|
onClick={() => removeSortOption(index)}
|
|
68
81
|
className="size-8 p-0"
|
|
82
|
+
aria-label="Remove sort criterion"
|
|
69
83
|
>
|
|
70
84
|
<X className="text-muted-foreground size-4" />
|
|
71
85
|
</Button>
|
|
@@ -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 {
|
|
4
|
+
import { Button } from "../primitives/button";
|
|
5
|
+
import { Navigation } from "./app-navigation";
|
|
5
6
|
|
|
6
|
-
const meta: Meta<typeof
|
|
7
|
+
const meta: Meta<typeof Navigation> = {
|
|
7
8
|
title: "Navigation/AppNavigation",
|
|
8
|
-
component:
|
|
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
|
|
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
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
<
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
37
|
-
|
|
80
|
+
export interface NavigationListProps {
|
|
81
|
+
children: ReactNode;
|
|
82
|
+
className?: string;
|
|
83
|
+
}
|
|
38
84
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
<
|
|
48
|
-
{...props}
|
|
89
|
+
<ul
|
|
49
90
|
className={cn(
|
|
50
|
-
"
|
|
51
|
-
|
|
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
|
-
</
|
|
97
|
+
</ul>
|
|
58
98
|
);
|
|
59
99
|
}
|
|
60
100
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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";
|
|
@@ -15,7 +15,7 @@ export function PaginationControls({
|
|
|
15
15
|
onPageChange,
|
|
16
16
|
}: PaginationControlsProps) {
|
|
17
17
|
return (
|
|
18
|
-
<div className="border-border bg-background text-foreground inline-flex items-center overflow-hidden rounded-full border text-sm">
|
|
18
|
+
<div className="border-border bg-background text-foreground inline-flex w-fit shrink-0 items-center self-start overflow-hidden rounded-full border text-sm">
|
|
19
19
|
<Button
|
|
20
20
|
type="button"
|
|
21
21
|
variant="outline"
|
package/src/lib/slot.tsx
ADDED
|
@@ -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";
|
package/src/styles/index.css
CHANGED
|
@@ -43,14 +43,16 @@
|
|
|
43
43
|
animation-name: zoom-out;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/*
|
|
47
|
-
|
|
46
|
+
/*
|
|
47
|
+
Values are generated from the DTCG source;
|
|
48
|
+
this file only names the roles consumed by web components.
|
|
49
|
+
*/
|
|
48
50
|
@theme inline {
|
|
49
51
|
--color-background: var(--background);
|
|
50
52
|
--color-foreground: var(--text-primary);
|
|
51
53
|
--color-card: var(--card);
|
|
52
54
|
--color-card-foreground: var(--text-primary);
|
|
53
|
-
--color-popover: var(--
|
|
55
|
+
--color-popover: var(--background);
|
|
54
56
|
--color-popover-foreground: var(--text-primary);
|
|
55
57
|
--color-primary: var(--primary);
|
|
56
58
|
--color-primary-foreground: var(--primary-foreground);
|