@nexub/patterns 1.3.0 → 1.4.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/README.md +132 -0
- package/dist/{chunk-7UPV63EW.js → chunk-5QFC4WFD.js} +5 -4
- package/dist/chunk-OYSWFXRD.js +329 -0
- package/dist/chunk-VYMTTYPR.js +1 -0
- package/dist/components/AppHeader/index.js +8 -0
- package/dist/components/AppLauncher/index.js +2 -1
- package/dist/components/SettingsLayout/index.js +1 -1
- package/dist/index.d.ts +69 -1
- package/dist/index.js +13 -8
- package/package.json +7 -7
- /package/dist/{chunk-WQX5FFVK.js → chunk-ZFHA46UX.js} +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# @nexub/patterns
|
|
2
|
+
|
|
3
|
+
Composite UI patterns for the Nexub Design System, built on `@nexub/react`.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @nexub/patterns @nexub/react @nexub/icons @nexub/themes
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Like `@nexub/react`, the patterns are styled with Tailwind classes your app compiles: add `node_modules/@nexub/patterns/dist/**/*.js` to your Tailwind `content`, use the `@nexub/tokens/tailwind` preset, and import `@nexub/themes/base` plus a palette (e.g. `@nexub/themes/light` and `@nexub/themes/dark`).
|
|
10
|
+
|
|
11
|
+
## AppHeader
|
|
12
|
+
|
|
13
|
+
The one top header every Nexub app shares. It is presentational: the app passes in its data and callbacks, and the header does no fetching.
|
|
14
|
+
|
|
15
|
+
Left to right: an optional menu slot (below `md` only), the logo, search, the organization, a theme switch, the app launcher and the account menu. It is fixed to the top at `--app-header-height` (64px, from `@nexub/themes/base`) and renders a spacer of that height after itself, so content flows below it with no padding of your own.
|
|
16
|
+
|
|
17
|
+
### Minimal
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { ThemeProvider } from '@nexub/react';
|
|
21
|
+
import { AppHeader } from '@nexub/patterns';
|
|
22
|
+
|
|
23
|
+
<ThemeProvider>
|
|
24
|
+
<AppHeader productName="Account" />
|
|
25
|
+
<main>…</main>
|
|
26
|
+
</ThemeProvider>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The theme switch uses `useTheme`, so it needs a `ThemeProvider`. Pass `showThemeSwitch={false}` to leave it out.
|
|
30
|
+
|
|
31
|
+
### Full
|
|
32
|
+
|
|
33
|
+
Search provider, two orgs, launcher and account menu:
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { useRouter } from 'next/navigation';
|
|
37
|
+
import { DropdownMenu } from '@nexub/react';
|
|
38
|
+
import { AppHeader, type AppHeaderSearchGroup } from '@nexub/patterns';
|
|
39
|
+
|
|
40
|
+
function Header({ session, currentOrgId, switchOrg, signOut, openPreferences }) {
|
|
41
|
+
const router = useRouter();
|
|
42
|
+
|
|
43
|
+
// Called (debounced) as the user types. Return groups of results.
|
|
44
|
+
const onSearch = async (query: string): Promise<AppHeaderSearchGroup[]> => {
|
|
45
|
+
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
|
46
|
+
const { projects } = await res.json();
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
label: 'Projects',
|
|
50
|
+
items: projects.map((p) => ({
|
|
51
|
+
id: p.id,
|
|
52
|
+
label: p.name,
|
|
53
|
+
hint: 'Project',
|
|
54
|
+
onSelect: () => router.push(`/projects/${p.id}`),
|
|
55
|
+
})),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<AppHeader
|
|
62
|
+
productName="Command Hub"
|
|
63
|
+
homeHref="/"
|
|
64
|
+
onNavigate={(href) => router.push(href)}
|
|
65
|
+
onSearch={onSearch}
|
|
66
|
+
orgs={[
|
|
67
|
+
{ id: 'acme', name: 'Acme' },
|
|
68
|
+
{ id: 'globex', name: 'Globex' },
|
|
69
|
+
]}
|
|
70
|
+
currentOrgId={currentOrgId}
|
|
71
|
+
onOrgChange={switchOrg}
|
|
72
|
+
apps={[
|
|
73
|
+
{ id: 'hub', name: 'Command Hub', href: '/', current: true },
|
|
74
|
+
{ id: 'account', name: 'Account', href: 'https://account.example.com' },
|
|
75
|
+
]}
|
|
76
|
+
user={{ name: session.name, email: session.email, avatarUrl: session.picture }}
|
|
77
|
+
accountHref="https://account.example.com"
|
|
78
|
+
privacyHref="/privacy"
|
|
79
|
+
termsHref="/terms"
|
|
80
|
+
onSignOut={signOut} // may return a promise; Sign out is disabled until it settles
|
|
81
|
+
accountMenuItems={<DropdownMenu.Item onSelect={openPreferences}>Preferences</DropdownMenu.Item>}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Props
|
|
88
|
+
|
|
89
|
+
| Prop | Notes |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `productName` | Shown after the wordmark (`<Logo size="sm" product={…} />`) and in the search field. |
|
|
92
|
+
| `homeHref`, `onNavigate(href)` | The logo links to `homeHref` (default `/`). `onNavigate` takes over plain clicks on the logo and the launcher's tiles, for client-side routers. |
|
|
93
|
+
| `menu` | Your navigation trigger, shown below `md` only. |
|
|
94
|
+
| `searchItems` / `onSearch(query)` | A static list (filtered as you type) or an async search (debounced 200ms, with loading, empty and error states). Groups are `{ label, items: [{ id, label, hint?, icon?, onSelect }] }`. With neither, there is no search. |
|
|
95
|
+
| `searchPlaceholder` | Default `Search <productName>`. |
|
|
96
|
+
| `orgs`, `currentOrgId`, `onOrgChange(id)` | No orgs: nothing. One: its name as plain, truncated text. Two or more: a menu with the current org checked; on phones the trigger is a building icon. |
|
|
97
|
+
| `showThemeSwitch` | Default `true`. |
|
|
98
|
+
| `apps` | Passed to `AppLauncher`. |
|
|
99
|
+
| `user`, `accountHref`, `privacyHref`, `termsHref`, `onSignOut`, `accountMenuItems` | The account menu (no `user`, no menu). Each link shows only when its href is set. `accountMenuItems` go above Sign out. |
|
|
100
|
+
| `surface` | `'solid'` (default) or `'glass'` (needs `@nexub/themes/glass`). |
|
|
101
|
+
|
|
102
|
+
Search opens with the button, **⌘K / Ctrl+K** anywhere, or **/** when focus isn't in a text field. Arrow keys move, Enter selects, Esc closes and returns focus.
|
|
103
|
+
|
|
104
|
+
### Layouts
|
|
105
|
+
|
|
106
|
+
- **Any page:** render it first; its spacer pushes content down. To offset something yourself (e.g. a sticky sidebar), use `top: var(--app-header-height)`.
|
|
107
|
+
- **AppShell:** put it in `AppShell.Header`. The spacer gives the header row its height, so `AppShell.Body` starts below it.
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
<AppShell.Root>
|
|
111
|
+
<AppShell.Header>
|
|
112
|
+
<AppHeader productName="Command Hub" menu={<MobileNavButton />} />
|
|
113
|
+
</AppShell.Header>
|
|
114
|
+
<AppShell.Body>…</AppShell.Body>
|
|
115
|
+
</AppShell.Root>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- **SettingsLayout:** pass `header`. It replaces SettingsLayout's own top bar and hands you its phone section-menu button for the menu slot.
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
<SettingsLayout items={items} activeId={active} header={(menu) => <AppHeader menu={menu} productName="Account" />}>
|
|
122
|
+
…
|
|
123
|
+
</SettingsLayout>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Strict CSP
|
|
127
|
+
|
|
128
|
+
The header injects no `<style>` tags and sets no inline styles of its own: the search palette locks page scroll with a class (`scrollLock="class"`), and the org and account menus are non-modal. One known gap: the colour Nexub mark inside `Logo` still sets `style` on its gradient stops, so under `style-src 'self'` a server-rendered mark loses its colours until that is fixed.
|
|
129
|
+
|
|
130
|
+
### Accessibility
|
|
131
|
+
|
|
132
|
+
`<header role="banner">` with a `<nav aria-label="Account and apps">` for the right-hand group. Every icon button has a label; the search buttons expose their shortcut through `aria-keyshortcuts` and a description. The org switcher and account menu are Radix menus, operable from the keyboard.
|
|
@@ -26,7 +26,7 @@ function SectionNav({
|
|
|
26
26
|
)) }) });
|
|
27
27
|
}
|
|
28
28
|
var Root = React.forwardRef(
|
|
29
|
-
({ items, activeId, onSelect, logo, actions, title, description, navLabel = "Settings", className, children, ...props }, ref) => {
|
|
29
|
+
({ items, activeId, onSelect, logo, actions, header, title, description, navLabel = "Settings", className, children, ...props }, ref) => {
|
|
30
30
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
|
31
31
|
const onChoose = (e, item) => {
|
|
32
32
|
if (!isPlainClick(e)) return;
|
|
@@ -36,9 +36,10 @@ var Root = React.forwardRef(
|
|
|
36
36
|
}
|
|
37
37
|
setMenuOpen(false);
|
|
38
38
|
};
|
|
39
|
+
const menuButton = /* @__PURE__ */ jsx(IconButton, { label: `Open ${navLabel.toLowerCase()} menu`, className: "-ml-2 md:hidden", onClick: () => setMenuOpen(true), children: /* @__PURE__ */ jsx(Menu, { size: "md", "aria-hidden": "true" }) });
|
|
39
40
|
return /* @__PURE__ */ jsxs("div", { ref, className: cn("flex min-h-screen flex-col bg-[var(--color-bg-default)]", className), ...props, children: [
|
|
40
|
-
/* @__PURE__ */ jsxs(TopNav.Root, { children: [
|
|
41
|
-
|
|
41
|
+
header ? header(menuButton) : /* @__PURE__ */ jsxs(TopNav.Root, { className: "h-[var(--app-header-height,64px)]", children: [
|
|
42
|
+
menuButton,
|
|
42
43
|
logo && /* @__PURE__ */ jsx(TopNav.Logo, { className: "min-w-0 shrink", children: logo }),
|
|
43
44
|
actions && /* @__PURE__ */ jsx(TopNav.Actions, { children: actions })
|
|
44
45
|
] }),
|
|
@@ -50,7 +51,7 @@ var Root = React.forwardRef(
|
|
|
50
51
|
activeId,
|
|
51
52
|
onChoose,
|
|
52
53
|
label: navLabel,
|
|
53
|
-
className: "sticky top-[var(--
|
|
54
|
+
className: "sticky top-[var(--app-header-height,64px)] hidden h-[calc(100vh-var(--app-header-height,64px))] shrink-0 md:flex"
|
|
54
55
|
}
|
|
55
56
|
),
|
|
56
57
|
/* @__PURE__ */ jsx("main", { className: "min-w-0 flex-1 px-4 py-6 md:px-8 md:py-10", children: /* @__PURE__ */ jsxs("div", { className: "mx-auto flex w-full max-w-3xl flex-col gap-6", children: [
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import {
|
|
3
|
+
AppLauncher
|
|
4
|
+
} from "./chunk-ZFHA46UX.js";
|
|
5
|
+
|
|
6
|
+
// src/components/AppHeader/AppHeader.tsx
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
import {
|
|
9
|
+
Avatar,
|
|
10
|
+
CommandPalette,
|
|
11
|
+
DropdownMenu,
|
|
12
|
+
IconButton,
|
|
13
|
+
Kbd,
|
|
14
|
+
Logo,
|
|
15
|
+
TopNav,
|
|
16
|
+
cn,
|
|
17
|
+
useTheme
|
|
18
|
+
} from "@nexub/react";
|
|
19
|
+
import { Building2, ChevronDown, Moon, Search, Sun } from "@nexub/icons";
|
|
20
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
21
|
+
var SEARCH_DEBOUNCE_MS = 200;
|
|
22
|
+
var focusRing = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring-default)] focus-visible:ring-offset-2 ring-offset-[var(--color-ring-offset)]";
|
|
23
|
+
var isPlainClick = (e) => e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey;
|
|
24
|
+
var isTextField = (t) => t instanceof HTMLElement && (t.isContentEditable || t.matches("input, textarea, select"));
|
|
25
|
+
function HeaderSearch({
|
|
26
|
+
placeholder,
|
|
27
|
+
items,
|
|
28
|
+
onSearch
|
|
29
|
+
}) {
|
|
30
|
+
const [open, setOpen] = React.useState(false);
|
|
31
|
+
const [query, setQuery] = React.useState("");
|
|
32
|
+
const [results, setResults] = React.useState([]);
|
|
33
|
+
const [loading, setLoading] = React.useState(false);
|
|
34
|
+
const [failed, setFailed] = React.useState(false);
|
|
35
|
+
const [isMac, setIsMac] = React.useState(true);
|
|
36
|
+
const hintId = React.useId();
|
|
37
|
+
const onSearchRef = React.useRef(onSearch);
|
|
38
|
+
onSearchRef.current = onSearch;
|
|
39
|
+
const isAsync = Boolean(onSearch);
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
setIsMac(/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent));
|
|
42
|
+
const onKeyDown = (e) => {
|
|
43
|
+
if (e.defaultPrevented) return;
|
|
44
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "k") {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
setOpen((o) => !o);
|
|
47
|
+
} else if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isTextField(e.target)) {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
setOpen(true);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
document.addEventListener("keydown", onKeyDown);
|
|
53
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
54
|
+
}, []);
|
|
55
|
+
React.useEffect(() => {
|
|
56
|
+
if (!open || !isAsync) return;
|
|
57
|
+
let stale = false;
|
|
58
|
+
setLoading(true);
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
Promise.resolve().then(() => onSearchRef.current(query)).then(
|
|
61
|
+
(groups2) => {
|
|
62
|
+
if (stale) return;
|
|
63
|
+
setResults(groups2);
|
|
64
|
+
setFailed(false);
|
|
65
|
+
},
|
|
66
|
+
() => {
|
|
67
|
+
if (stale) return;
|
|
68
|
+
setResults([]);
|
|
69
|
+
setFailed(true);
|
|
70
|
+
}
|
|
71
|
+
).finally(() => {
|
|
72
|
+
if (!stale) setLoading(false);
|
|
73
|
+
});
|
|
74
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
75
|
+
return () => {
|
|
76
|
+
stale = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
};
|
|
79
|
+
}, [open, query, isAsync]);
|
|
80
|
+
const onOpenChange = (next) => {
|
|
81
|
+
setOpen(next);
|
|
82
|
+
if (!next) setQuery("");
|
|
83
|
+
};
|
|
84
|
+
const groups = isAsync ? results : items ?? [];
|
|
85
|
+
const triggerProps = {
|
|
86
|
+
onClick: () => setOpen(true),
|
|
87
|
+
"aria-keyshortcuts": "Meta+K Control+K /",
|
|
88
|
+
"aria-describedby": hintId,
|
|
89
|
+
"aria-haspopup": "dialog"
|
|
90
|
+
};
|
|
91
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
92
|
+
/* @__PURE__ */ jsxs("span", { id: hintId, className: "sr-only", children: [
|
|
93
|
+
"Keyboard shortcut: ",
|
|
94
|
+
isMac ? "Command" : "Control",
|
|
95
|
+
" K"
|
|
96
|
+
] }),
|
|
97
|
+
/* @__PURE__ */ jsxs(
|
|
98
|
+
"button",
|
|
99
|
+
{
|
|
100
|
+
type: "button",
|
|
101
|
+
...triggerProps,
|
|
102
|
+
className: cn(
|
|
103
|
+
"hidden h-9 w-full max-w-xl items-center gap-2 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-subtle)] px-3 text-sm text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-muted)] md:flex",
|
|
104
|
+
focusRing
|
|
105
|
+
),
|
|
106
|
+
children: [
|
|
107
|
+
/* @__PURE__ */ jsx(Search, { size: "sm", "aria-hidden": "true", className: "shrink-0" }),
|
|
108
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: placeholder }),
|
|
109
|
+
/* @__PURE__ */ jsx(Kbd, { "aria-hidden": "true", className: "shrink-0", children: isMac ? "\u2318K" : "Ctrl K" })
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
),
|
|
113
|
+
/* @__PURE__ */ jsx(IconButton, { label: placeholder, ...triggerProps, className: "md:hidden", children: /* @__PURE__ */ jsx(Search, { size: "md", "aria-hidden": "true" }) }),
|
|
114
|
+
/* @__PURE__ */ jsxs(
|
|
115
|
+
CommandPalette.Root,
|
|
116
|
+
{
|
|
117
|
+
open,
|
|
118
|
+
onOpenChange,
|
|
119
|
+
label: placeholder,
|
|
120
|
+
scrollLock: "class",
|
|
121
|
+
shouldFilter: !isAsync,
|
|
122
|
+
children: [
|
|
123
|
+
/* @__PURE__ */ jsx(CommandPalette.Input, { placeholder, value: query, onValueChange: setQuery }),
|
|
124
|
+
/* @__PURE__ */ jsxs(CommandPalette.List, { children: [
|
|
125
|
+
loading && /* @__PURE__ */ jsx("div", { role: "status", className: "px-3 py-2 text-sm text-[var(--color-text-muted)]", children: "Searching\u2026" }),
|
|
126
|
+
!loading && /* @__PURE__ */ jsx(CommandPalette.Empty, { children: failed ? "Search failed. Try again." : query ? `No results for \u201C${query}\u201D` : "Type to search" }),
|
|
127
|
+
groups.map((group) => /* @__PURE__ */ jsx(CommandPalette.Group, { heading: group.label, children: group.items.map((item) => /* @__PURE__ */ jsxs(
|
|
128
|
+
CommandPalette.Item,
|
|
129
|
+
{
|
|
130
|
+
value: `${group.label}:${item.id}`,
|
|
131
|
+
keywords: [item.label],
|
|
132
|
+
onSelect: () => {
|
|
133
|
+
onOpenChange(false);
|
|
134
|
+
item.onSelect();
|
|
135
|
+
},
|
|
136
|
+
children: [
|
|
137
|
+
item.icon && /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "mr-2 flex shrink-0 text-[var(--color-text-muted)]", children: item.icon }),
|
|
138
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: item.label }),
|
|
139
|
+
item.hint && /* @__PURE__ */ jsx("span", { className: "ml-auto shrink-0 pl-3 text-xs text-[var(--color-text-muted)]", children: item.hint })
|
|
140
|
+
]
|
|
141
|
+
},
|
|
142
|
+
item.id
|
|
143
|
+
)) }, group.label))
|
|
144
|
+
] })
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
] });
|
|
149
|
+
}
|
|
150
|
+
var orgLabelClass = "min-w-0 max-w-[6rem] truncate sm:max-w-[8rem] xl:max-w-[12rem]";
|
|
151
|
+
function OrgSwitcher({
|
|
152
|
+
orgs,
|
|
153
|
+
currentOrgId,
|
|
154
|
+
onOrgChange
|
|
155
|
+
}) {
|
|
156
|
+
const current = orgs.find((o) => o.id === currentOrgId) ?? orgs[0];
|
|
157
|
+
if (!current) return null;
|
|
158
|
+
if (orgs.length === 1) {
|
|
159
|
+
return /* @__PURE__ */ jsx("span", { title: current.name, className: cn("px-2 text-sm font-medium text-[var(--color-text-secondary)]", orgLabelClass), children: current.name });
|
|
160
|
+
}
|
|
161
|
+
return (
|
|
162
|
+
// modal={false}: Radix's modal menu injects a <style> scroll lock (strict CSP).
|
|
163
|
+
/* @__PURE__ */ jsxs(DropdownMenu.Root, { modal: false, children: [
|
|
164
|
+
/* @__PURE__ */ jsx(DropdownMenu.Trigger, { asChild: true, children: /* @__PURE__ */ jsxs(
|
|
165
|
+
"button",
|
|
166
|
+
{
|
|
167
|
+
type: "button",
|
|
168
|
+
"aria-label": `${current.name}, switch organization`,
|
|
169
|
+
title: current.name,
|
|
170
|
+
className: cn(
|
|
171
|
+
"inline-flex h-9 min-w-0 shrink-0 items-center gap-1 rounded-md px-2 text-sm sm:shrink font-medium text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-bg-subtle)]",
|
|
172
|
+
focusRing
|
|
173
|
+
),
|
|
174
|
+
children: [
|
|
175
|
+
/* @__PURE__ */ jsx(Building2, { size: "md", "aria-hidden": "true", className: "shrink-0 sm:hidden" }),
|
|
176
|
+
/* @__PURE__ */ jsx("span", { className: cn("hidden sm:inline", orgLabelClass), children: current.name }),
|
|
177
|
+
/* @__PURE__ */ jsx(ChevronDown, { size: "sm", "aria-hidden": "true", className: "shrink-0 text-[var(--color-text-muted)]" })
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
) }),
|
|
181
|
+
/* @__PURE__ */ jsx(DropdownMenu.Portal, { children: /* @__PURE__ */ jsxs(DropdownMenu.Content, { align: "end", className: "max-w-[calc(100vw-1rem)]", children: [
|
|
182
|
+
/* @__PURE__ */ jsx(DropdownMenu.Label, { children: "Organizations" }),
|
|
183
|
+
/* @__PURE__ */ jsx(DropdownMenu.RadioGroup, { value: current.id, onValueChange: (id) => onOrgChange?.(id), children: orgs.map((org) => /* @__PURE__ */ jsx(DropdownMenu.RadioItem, { value: org.id, children: /* @__PURE__ */ jsx("span", { className: "truncate", children: org.name }) }, org.id)) })
|
|
184
|
+
] }) })
|
|
185
|
+
] })
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
function ThemeSwitch() {
|
|
189
|
+
const { resolvedTheme, setTheme } = useTheme();
|
|
190
|
+
const dark = resolvedTheme === "dark";
|
|
191
|
+
return /* @__PURE__ */ jsx(IconButton, { label: dark ? "Switch to light theme" : "Switch to dark theme", onClick: () => setTheme(dark ? "light" : "dark"), children: dark ? /* @__PURE__ */ jsx(Sun, { size: "md", "aria-hidden": "true" }) : /* @__PURE__ */ jsx(Moon, { size: "md", "aria-hidden": "true" }) });
|
|
192
|
+
}
|
|
193
|
+
var GLASS_LABELS = { clear: "Clear", balanced: "Balanced", tinted: "Tinted", off: "Solid" };
|
|
194
|
+
function GlassItems() {
|
|
195
|
+
const { glass, setGlass } = useTheme();
|
|
196
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
197
|
+
/* @__PURE__ */ jsx(DropdownMenu.Separator, {}),
|
|
198
|
+
/* @__PURE__ */ jsx(DropdownMenu.Label, { children: "Glass" }),
|
|
199
|
+
/* @__PURE__ */ jsx(DropdownMenu.RadioGroup, { value: glass, onValueChange: (v) => setGlass(v), children: Object.keys(GLASS_LABELS).map((mode) => /* @__PURE__ */ jsx(DropdownMenu.RadioItem, { value: mode, onSelect: (e) => e.preventDefault(), children: GLASS_LABELS[mode] }, mode)) })
|
|
200
|
+
] });
|
|
201
|
+
}
|
|
202
|
+
function AccountMenu({
|
|
203
|
+
user,
|
|
204
|
+
accountHref,
|
|
205
|
+
privacyHref,
|
|
206
|
+
termsHref,
|
|
207
|
+
onSignOut,
|
|
208
|
+
accountMenuItems,
|
|
209
|
+
glassControl
|
|
210
|
+
}) {
|
|
211
|
+
const [open, setOpen] = React.useState(false);
|
|
212
|
+
const [signingOut, setSigningOut] = React.useState(false);
|
|
213
|
+
const signOut = (e) => {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
if (!onSignOut || signingOut) return;
|
|
216
|
+
setSigningOut(true);
|
|
217
|
+
Promise.resolve().then(onSignOut).finally(() => {
|
|
218
|
+
setSigningOut(false);
|
|
219
|
+
setOpen(false);
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
const link = (href, label) => /* @__PURE__ */ jsx(DropdownMenu.Item, { asChild: true, children: /* @__PURE__ */ jsx("a", { href, children: label }) });
|
|
223
|
+
const legal = (privacyHref || termsHref) && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
224
|
+
privacyHref && link(privacyHref, "Privacy policy"),
|
|
225
|
+
termsHref && link(termsHref, "Terms of service")
|
|
226
|
+
] });
|
|
227
|
+
const last = (accountMenuItems || onSignOut) && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
228
|
+
accountMenuItems,
|
|
229
|
+
onSignOut && /* @__PURE__ */ jsx(DropdownMenu.Item, { disabled: signingOut, onSelect: signOut, children: signingOut ? "Signing out\u2026" : "Sign out" })
|
|
230
|
+
] });
|
|
231
|
+
return (
|
|
232
|
+
// modal={false}: Radix's modal menu injects a <style> scroll lock (strict CSP).
|
|
233
|
+
/* @__PURE__ */ jsxs(DropdownMenu.Root, { open, onOpenChange: setOpen, modal: false, children: [
|
|
234
|
+
/* @__PURE__ */ jsx(DropdownMenu.Trigger, { asChild: true, children: /* @__PURE__ */ jsx("button", { type: "button", "aria-label": `Account: ${user.name}`, className: cn("ml-1 shrink-0 rounded-full", focusRing), children: /* @__PURE__ */ jsx(Avatar, { name: user.name, src: user.avatarUrl, size: "sm", "aria-hidden": "true" }) }) }),
|
|
235
|
+
/* @__PURE__ */ jsx(DropdownMenu.Portal, { children: /* @__PURE__ */ jsxs(DropdownMenu.Content, { align: "end", className: "w-72 max-w-[calc(100vw-1rem)]", children: [
|
|
236
|
+
/* @__PURE__ */ jsxs(DropdownMenu.Label, { className: "flex flex-col gap-0.5 py-2", children: [
|
|
237
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-title-sm text-[var(--color-on-surface)]", children: user.name }),
|
|
238
|
+
user.email && /* @__PURE__ */ jsx("span", { className: "truncate text-body-sm text-[var(--color-on-surface-muted)]", children: user.email })
|
|
239
|
+
] }),
|
|
240
|
+
accountHref && link(accountHref, "Manage your Nexub Account"),
|
|
241
|
+
glassControl && /* @__PURE__ */ jsx(GlassItems, {}),
|
|
242
|
+
legal && /* @__PURE__ */ jsx(DropdownMenu.Separator, {}),
|
|
243
|
+
legal,
|
|
244
|
+
last && /* @__PURE__ */ jsx(DropdownMenu.Separator, {}),
|
|
245
|
+
last
|
|
246
|
+
] }) })
|
|
247
|
+
] })
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
var AppHeader = React.forwardRef(
|
|
251
|
+
({
|
|
252
|
+
productName,
|
|
253
|
+
homeHref = "/",
|
|
254
|
+
onNavigate,
|
|
255
|
+
menu,
|
|
256
|
+
searchPlaceholder,
|
|
257
|
+
searchItems,
|
|
258
|
+
onSearch,
|
|
259
|
+
orgs = [],
|
|
260
|
+
currentOrgId,
|
|
261
|
+
onOrgChange,
|
|
262
|
+
showThemeSwitch = true,
|
|
263
|
+
apps,
|
|
264
|
+
user,
|
|
265
|
+
accountHref,
|
|
266
|
+
privacyHref,
|
|
267
|
+
termsHref,
|
|
268
|
+
onSignOut,
|
|
269
|
+
accountMenuItems,
|
|
270
|
+
surface = "solid",
|
|
271
|
+
className,
|
|
272
|
+
...props
|
|
273
|
+
}, ref) => {
|
|
274
|
+
const placeholder = searchPlaceholder ?? (productName ? `Search ${productName}` : "Search");
|
|
275
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
276
|
+
/* @__PURE__ */ jsx(
|
|
277
|
+
TopNav.Root,
|
|
278
|
+
{
|
|
279
|
+
ref,
|
|
280
|
+
role: "banner",
|
|
281
|
+
surface,
|
|
282
|
+
className: cn("fixed inset-x-0 top-0 h-[var(--app-header-height,64px)] px-2 sm:px-4", className),
|
|
283
|
+
...props,
|
|
284
|
+
children: /* @__PURE__ */ jsxs("div", { className: "flex w-full min-w-0 items-center gap-1 sm:gap-2", children: [
|
|
285
|
+
menu && /* @__PURE__ */ jsx("div", { className: "flex shrink-0 md:hidden", children: menu }),
|
|
286
|
+
/* @__PURE__ */ jsx(
|
|
287
|
+
"a",
|
|
288
|
+
{
|
|
289
|
+
href: homeHref,
|
|
290
|
+
onClick: (e) => {
|
|
291
|
+
if (onNavigate && isPlainClick(e)) {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
onNavigate(homeHref);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
className: cn("flex min-w-0 shrink items-center rounded-md p-1", focusRing),
|
|
297
|
+
children: /* @__PURE__ */ jsx(Logo, { size: "sm", product: productName })
|
|
298
|
+
}
|
|
299
|
+
),
|
|
300
|
+
/* @__PURE__ */ jsx("div", { className: "ml-auto flex shrink-0 md:ml-0 md:min-w-0 md:flex-1 md:justify-center md:px-2 lg:px-4", children: (onSearch || searchItems) && /* @__PURE__ */ jsx(HeaderSearch, { placeholder, items: searchItems, onSearch }) }),
|
|
301
|
+
/* @__PURE__ */ jsxs("nav", { "aria-label": "Account and apps", className: "flex shrink-0 items-center gap-0.5 sm:gap-1", children: [
|
|
302
|
+
orgs.length > 0 && /* @__PURE__ */ jsx(OrgSwitcher, { orgs, currentOrgId, onOrgChange }),
|
|
303
|
+
showThemeSwitch && /* @__PURE__ */ jsx(ThemeSwitch, {}),
|
|
304
|
+
apps && /* @__PURE__ */ jsx(AppLauncher, { apps, surface, onNavigate: onNavigate && ((app) => onNavigate(app.href)) }),
|
|
305
|
+
user && /* @__PURE__ */ jsx(
|
|
306
|
+
AccountMenu,
|
|
307
|
+
{
|
|
308
|
+
user,
|
|
309
|
+
accountHref,
|
|
310
|
+
privacyHref,
|
|
311
|
+
termsHref,
|
|
312
|
+
onSignOut,
|
|
313
|
+
accountMenuItems,
|
|
314
|
+
glassControl: showThemeSwitch && surface === "glass"
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
] })
|
|
318
|
+
] })
|
|
319
|
+
}
|
|
320
|
+
),
|
|
321
|
+
/* @__PURE__ */ jsx("div", { "aria-hidden": "true", className: "h-[var(--app-header-height,64px)] shrink-0" })
|
|
322
|
+
] });
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
AppHeader.displayName = "AppHeader";
|
|
326
|
+
|
|
327
|
+
export {
|
|
328
|
+
AppHeader
|
|
329
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
'use client';
|
package/dist/index.d.ts
CHANGED
|
@@ -152,6 +152,12 @@ interface SettingsLayoutProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
|
|
|
152
152
|
logo?: React.ReactNode;
|
|
153
153
|
/** Top bar, right: theme switch, `<AppLauncher>`, account menu. */
|
|
154
154
|
actions?: React.ReactNode;
|
|
155
|
+
/**
|
|
156
|
+
* Replaces the built-in top bar (and `logo`/`actions`), usually with an `AppHeader`.
|
|
157
|
+
* Gets the phone section-menu button to place in it:
|
|
158
|
+
* `header={(menu) => <AppHeader menu={menu} … />}`.
|
|
159
|
+
*/
|
|
160
|
+
header?: (menuButton: React.ReactElement) => React.ReactNode;
|
|
155
161
|
/** Page header. */
|
|
156
162
|
title?: React.ReactNode;
|
|
157
163
|
description?: React.ReactNode;
|
|
@@ -178,4 +184,66 @@ declare const SettingsLayout: React.ForwardRefExoticComponent<SettingsLayoutProp
|
|
|
178
184
|
Row: React.ForwardRefExoticComponent<SettingsRowProps & React.RefAttributes<HTMLDivElement>>;
|
|
179
185
|
};
|
|
180
186
|
|
|
181
|
-
|
|
187
|
+
interface AppHeaderSearchItem {
|
|
188
|
+
id: string;
|
|
189
|
+
label: string;
|
|
190
|
+
/** Shown muted on the right, e.g. "Project" or a shortcut. */
|
|
191
|
+
hint?: React.ReactNode;
|
|
192
|
+
icon?: React.ReactNode;
|
|
193
|
+
onSelect: () => void;
|
|
194
|
+
}
|
|
195
|
+
interface AppHeaderSearchGroup {
|
|
196
|
+
label: string;
|
|
197
|
+
items: AppHeaderSearchItem[];
|
|
198
|
+
}
|
|
199
|
+
interface AppHeaderOrg {
|
|
200
|
+
id: string;
|
|
201
|
+
name: string;
|
|
202
|
+
}
|
|
203
|
+
interface AppHeaderUser {
|
|
204
|
+
name: string;
|
|
205
|
+
email?: string;
|
|
206
|
+
avatarUrl?: string;
|
|
207
|
+
}
|
|
208
|
+
interface AppHeaderProps extends Omit<React.HTMLAttributes<HTMLElement>, 'children'> {
|
|
209
|
+
/** e.g. "Command Hub". Shown after the Nexub wordmark and in the search field. */
|
|
210
|
+
productName?: string;
|
|
211
|
+
/** Where the logo links. @default '/' */
|
|
212
|
+
homeHref?: string;
|
|
213
|
+
/** Client-side routing for the logo and launcher. When set, plain clicks call this instead of following the link. */
|
|
214
|
+
onNavigate?: (href: string) => void;
|
|
215
|
+
/** The app's own navigation trigger. Shown below `md` only. */
|
|
216
|
+
menu?: React.ReactNode;
|
|
217
|
+
/** @default `Search ${productName}` */
|
|
218
|
+
searchPlaceholder?: string;
|
|
219
|
+
/** Static results, filtered as the user types. */
|
|
220
|
+
searchItems?: AppHeaderSearchGroup[];
|
|
221
|
+
/** Async results (debounced). Takes precedence over `searchItems`. */
|
|
222
|
+
onSearch?: (query: string) => Promise<AppHeaderSearchGroup[]>;
|
|
223
|
+
orgs?: AppHeaderOrg[];
|
|
224
|
+
currentOrgId?: string;
|
|
225
|
+
onOrgChange?: (id: string) => void;
|
|
226
|
+
/** Needs a `ThemeProvider`. @default true */
|
|
227
|
+
showThemeSwitch?: boolean;
|
|
228
|
+
/** Passed to `AppLauncher`. No apps, no launcher. */
|
|
229
|
+
apps?: AppLauncherApp[];
|
|
230
|
+
/** No user, no account menu. */
|
|
231
|
+
user?: AppHeaderUser;
|
|
232
|
+
accountHref?: string;
|
|
233
|
+
privacyHref?: string;
|
|
234
|
+
termsHref?: string;
|
|
235
|
+
/** The menu stays open, with Sign out disabled, until the promise settles. */
|
|
236
|
+
onSignOut?: () => void | Promise<unknown>;
|
|
237
|
+
/** Extra `DropdownMenu.Item`s, shown above Sign out. */
|
|
238
|
+
accountMenuItems?: React.ReactNode;
|
|
239
|
+
surface?: 'solid' | 'glass';
|
|
240
|
+
/** `soft`: page content dissolves under the header instead of meeting a divider. */
|
|
241
|
+
scrollEdge?: 'soft' | 'hard';
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The one top header every Nexub app shares. Fixed to the top; renders a
|
|
245
|
+
* spacer of `--app-header-height` after itself, so content starts below it.
|
|
246
|
+
*/
|
|
247
|
+
declare const AppHeader: React.ForwardRefExoticComponent<AppHeaderProps & React.RefAttributes<HTMLElement>>;
|
|
248
|
+
|
|
249
|
+
export { ActivityFeed, type ActivityItemProps, AppHeader, type AppHeaderOrg, type AppHeaderProps, type AppHeaderSearchGroup, type AppHeaderSearchItem, type AppHeaderUser, AppLauncher, type AppLauncherApp, type AppLauncherProps, AuthCard, type AuthCardProps, FilterBar, type FilterBarProps, InfoList, type InfoListItemProps, type InfoListRootProps, NotificationItem, type NotificationItemProps, PlanCard, type PlanCardProps, SettingsLayout, type SettingsLayoutItem, type SettingsLayoutProps, type SettingsLayoutSectionProps, SettingsRow, type SettingsRowProps, SettingsSection, type SettingsSectionProps, StatCard, type StatCardProps, StepIndicator, type StepIndicatorProps, type StepIndicatorStep, UserCard, type UserCardProps };
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
import {
|
|
3
3
|
SettingsLayout,
|
|
4
4
|
SettingsRow
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-5QFC4WFD.js";
|
|
6
|
+
import {
|
|
7
|
+
PlanCard
|
|
8
|
+
} from "./chunk-QMYBXQG4.js";
|
|
6
9
|
import {
|
|
7
10
|
SettingsSection
|
|
8
11
|
} from "./chunk-OTNHVOPC.js";
|
|
@@ -15,29 +18,31 @@ import {
|
|
|
15
18
|
import {
|
|
16
19
|
UserCard
|
|
17
20
|
} from "./chunk-AEYLZU3F.js";
|
|
21
|
+
import {
|
|
22
|
+
AppHeader
|
|
23
|
+
} from "./chunk-OYSWFXRD.js";
|
|
18
24
|
import {
|
|
19
25
|
ActivityFeed
|
|
20
26
|
} from "./chunk-YX7UO577.js";
|
|
27
|
+
import "./chunk-VYMTTYPR.js";
|
|
21
28
|
import {
|
|
22
29
|
AppLauncher
|
|
23
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-ZFHA46UX.js";
|
|
24
31
|
import {
|
|
25
32
|
AuthCard
|
|
26
33
|
} from "./chunk-AOE6GQSH.js";
|
|
27
|
-
import {
|
|
28
|
-
InfoList
|
|
29
|
-
} from "./chunk-74JVLMLE.js";
|
|
30
34
|
import {
|
|
31
35
|
FilterBar
|
|
32
36
|
} from "./chunk-EOA5C3Q4.js";
|
|
37
|
+
import {
|
|
38
|
+
InfoList
|
|
39
|
+
} from "./chunk-74JVLMLE.js";
|
|
33
40
|
import {
|
|
34
41
|
NotificationItem
|
|
35
42
|
} from "./chunk-Y3TNGDKC.js";
|
|
36
|
-
import {
|
|
37
|
-
PlanCard
|
|
38
|
-
} from "./chunk-QMYBXQG4.js";
|
|
39
43
|
export {
|
|
40
44
|
ActivityFeed,
|
|
45
|
+
AppHeader,
|
|
41
46
|
AppLauncher,
|
|
42
47
|
AuthCard,
|
|
43
48
|
FilterBar,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nexub/patterns",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Composite UI patterns for Nexub Design System — pre-built, production-ready layouts and widgets built on @nexub/react.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"react": ">=18 <20",
|
|
38
38
|
"react-dom": ">=18 <20",
|
|
39
|
-
"@nexub/react": "^1.
|
|
40
|
-
"@nexub/icons": "^1.
|
|
41
|
-
"@nexub/tokens": "^1.
|
|
39
|
+
"@nexub/react": "^1.4.0",
|
|
40
|
+
"@nexub/icons": "^1.4.0",
|
|
41
|
+
"@nexub/tokens": "^1.4.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@nexub/icons": {
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
"tsup": "^8.3.5",
|
|
59
59
|
"typescript": "^5.6.3",
|
|
60
60
|
"vitest": "^2.1.8",
|
|
61
|
-
"@nexub/icons": "1.
|
|
62
|
-
"@nexub/react": "1.
|
|
63
|
-
"@nexub/tokens": "1.
|
|
61
|
+
"@nexub/icons": "1.4.0",
|
|
62
|
+
"@nexub/react": "1.4.0",
|
|
63
|
+
"@nexub/tokens": "1.4.0"
|
|
64
64
|
},
|
|
65
65
|
"scripts": {
|
|
66
66
|
"build": "tsup",
|
|
File without changes
|