@medalsocial/meda 1.5.0 → 1.6.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 +46 -0
- package/dist/recipes/next.js +33 -1
- package/dist/shell/app-shell-auth.js +1 -1
- package/dist/shell/app-shell-workspace.d.ts +8 -3
- package/dist/shell/app-shell-workspace.js +13 -11
- package/dist/shell/app-shell.d.ts +25 -1
- package/dist/shell/app-shell.js +1 -1
- package/dist/shell/index.d.ts +2 -1
- package/dist/shell/index.js +0 -1
- package/dist/shell/internal/mobile-drawers.d.ts +6 -2
- package/dist/shell/internal/mobile-drawers.js +53 -6
- package/dist/shell/shell-header.d.ts +11 -3
- package/dist/shell/shell-header.js +28 -16
- package/dist/shell/shell-provider.js +2 -3
- package/dist/shell/theme-next-themes.d.ts +1 -1
- package/dist/shell/theme-next-themes.js +107 -16
- package/dist/shell/types.d.ts +17 -1
- package/dist/styles/theme.css +7 -0
- package/dist/styles/theme.test.ts +12 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,28 @@ Peer deps: `react >= 19`, `react-dom >= 19`, and `lucide-react`.
|
|
|
17
17
|
Meda ships a `styles.css` with its design tokens. Import it once in your entry stylesheet or entry script:
|
|
18
18
|
|
|
19
19
|
```css
|
|
20
|
+
@import 'tailwindcss';
|
|
21
|
+
@import '@medalsocial/meda/styles.css';
|
|
22
|
+
|
|
23
|
+
:root {
|
|
24
|
+
/* Consumer overrides go after meda so equal-specificity tokens win by source order. */
|
|
25
|
+
--color-brand-500: oklch(0.62 0.18 245);
|
|
26
|
+
--auth-gradient-primary: var(--color-brand-500);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.dark {
|
|
30
|
+
--color-brand-500: oklch(0.72 0.16 245);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Avoid placing token overrides before the Meda import; `tokens.css` defines the package defaults and later declarations are what override them:
|
|
35
|
+
|
|
36
|
+
```css
|
|
37
|
+
/* Wrong: meda's imported defaults overwrite this block. */
|
|
38
|
+
:root {
|
|
39
|
+
--color-brand-500: oklch(0.62 0.18 245);
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
@import '@medalsocial/meda/styles.css';
|
|
21
43
|
```
|
|
22
44
|
|
|
@@ -58,11 +80,35 @@ import Link from 'next/link';
|
|
|
58
80
|
mainItems,
|
|
59
81
|
renderLink: ({ item, linkProps }) => <Link {...linkProps} href={item.to} prefetch />,
|
|
60
82
|
}}
|
|
83
|
+
appTabs={{
|
|
84
|
+
renderLink: ({ app, linkProps }) =>
|
|
85
|
+
app.to ? <Link {...linkProps} href={app.to} prefetch /> : <a {...linkProps} />,
|
|
86
|
+
}}
|
|
61
87
|
>
|
|
62
88
|
{children}
|
|
63
89
|
</AppShell>;
|
|
64
90
|
```
|
|
65
91
|
|
|
92
|
+
Workspace shells also expose chrome-level composition slots:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
<AppShell
|
|
96
|
+
variant="workspace"
|
|
97
|
+
headerCenter={<SectionTabs />}
|
|
98
|
+
banners={<SystemHealthBanner />}
|
|
99
|
+
mainLayout="fullbleed"
|
|
100
|
+
mainClassName="marketing-main"
|
|
101
|
+
workspace={{
|
|
102
|
+
menuItems: [{ id: 'settings', label: 'Settings', href: '/settings' }],
|
|
103
|
+
menuFooter: <AccountSwitcher />,
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{children}
|
|
107
|
+
</AppShell>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`workspace.menuItems`, `workspace.menuFooter`, and the theme toggle are available from the mobile Menu drawer. Use `mainLayout`/`mainClassName` when a workspace shell needs the same mobile chrome but a custom main scroll region, such as a full-bleed marketing page. `useCommands()` works from workspace descendants without manually mounting `CommandPalette`; lower-level primitive compositions can still mount `CommandPalette` directly.
|
|
111
|
+
|
|
66
112
|
For app-scoped brand tokens:
|
|
67
113
|
|
|
68
114
|
```ts
|
package/dist/recipes/next.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const nextAppShellRecipe = {
|
|
2
2
|
name: 'meda-next-app-shell',
|
|
3
3
|
title: 'Meda Next AppShell',
|
|
4
|
-
description: 'Copyable Next.js App Router shell adapter with next/link routing, route-owned panel views, and auth controls.',
|
|
4
|
+
description: 'Copyable Next.js App Router shell adapter with next/link routing, route-owned panel views, mobile workspace menus, full-bleed main layout hooks, and auth controls.',
|
|
5
5
|
dependencies: ['@medalsocial/meda', 'lucide-react'],
|
|
6
6
|
peerDependencies: ['next', 'react', 'react-dom'],
|
|
7
7
|
cssVars: ['@medalsocial/meda/styles.css'],
|
|
@@ -25,9 +25,12 @@ import {
|
|
|
25
25
|
MedaShellProvider,
|
|
26
26
|
PanelViewsProvider,
|
|
27
27
|
type AppDefinition,
|
|
28
|
+
type AppShellAppTabsConfig,
|
|
28
29
|
type IconRailItem,
|
|
29
30
|
type PanelView,
|
|
31
|
+
type ShellMainLayout,
|
|
30
32
|
type WorkspaceDefinition,
|
|
33
|
+
type WorkspaceMenuItem,
|
|
31
34
|
} from '@medalsocial/meda/shell'
|
|
32
35
|
|
|
33
36
|
export function MedaNextWorkspaceShell({
|
|
@@ -37,6 +40,13 @@ export function MedaNextWorkspaceShell({
|
|
|
37
40
|
activeIconId,
|
|
38
41
|
panelViews = [],
|
|
39
42
|
defaultPanelView,
|
|
43
|
+
appTabs,
|
|
44
|
+
headerCenter,
|
|
45
|
+
banners,
|
|
46
|
+
mainLayout,
|
|
47
|
+
mainClassName,
|
|
48
|
+
workspaceMenuItems,
|
|
49
|
+
workspaceMenuFooter,
|
|
40
50
|
children,
|
|
41
51
|
}: {
|
|
42
52
|
workspace: WorkspaceDefinition
|
|
@@ -45,6 +55,13 @@ export function MedaNextWorkspaceShell({
|
|
|
45
55
|
activeIconId?: string
|
|
46
56
|
panelViews?: PanelView[]
|
|
47
57
|
defaultPanelView?: string
|
|
58
|
+
appTabs?: AppShellAppTabsConfig
|
|
59
|
+
headerCenter?: ReactNode
|
|
60
|
+
banners?: ReactNode
|
|
61
|
+
mainLayout?: ShellMainLayout
|
|
62
|
+
mainClassName?: string
|
|
63
|
+
workspaceMenuItems?: WorkspaceMenuItem[]
|
|
64
|
+
workspaceMenuFooter?: ReactNode
|
|
48
65
|
children: ReactNode
|
|
49
66
|
}) {
|
|
50
67
|
return (
|
|
@@ -58,6 +75,17 @@ export function MedaNextWorkspaceShell({
|
|
|
58
75
|
<Link {...linkProps} href={item.to} prefetch />
|
|
59
76
|
),
|
|
60
77
|
}}
|
|
78
|
+
appTabs={
|
|
79
|
+
appTabs ?? {
|
|
80
|
+
renderLink: ({ app, linkProps }) =>
|
|
81
|
+
app.to ? <Link {...linkProps} href={app.to} prefetch /> : <a {...linkProps} />,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
headerCenter={headerCenter}
|
|
85
|
+
banners={banners}
|
|
86
|
+
mainLayout={mainLayout}
|
|
87
|
+
mainClassName={mainClassName}
|
|
88
|
+
workspace={{ menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }}
|
|
61
89
|
rightPanel={{ panelViews, defaultView: defaultPanelView }}
|
|
62
90
|
>
|
|
63
91
|
<PanelViewsProvider views={panelViews} defaultView={defaultPanelView}>
|
|
@@ -114,6 +142,7 @@ export function MedaNextAuthShell({
|
|
|
114
142
|
accessibility: [
|
|
115
143
|
'Every drawer and panel keeps its accessible name from AppShell and RightPanel.',
|
|
116
144
|
'Custom link renderers must forward all linkProps to preserve aria-current, labels, handlers, and className.',
|
|
145
|
+
'Workspace menu items render in desktop and mobile chrome, so critical actions stay reachable across viewports.',
|
|
117
146
|
'Auth provider buttons keep the visible provider affordance separate from the accessible button name.',
|
|
118
147
|
'Route-owned panel views should expose headings inside their rendered panel content.',
|
|
119
148
|
'Reduced-motion behavior remains delegated to Meda shell motion tokens.',
|
|
@@ -121,6 +150,9 @@ export function MedaNextAuthShell({
|
|
|
121
150
|
composition: [
|
|
122
151
|
'MedaShellProvider owns workspace and app context for the copied shell adapter.',
|
|
123
152
|
'AppShell receives route-owned rightPanel views on first render to avoid delayed panel UI.',
|
|
153
|
+
'AppShellWorkspace mounts CommandPalette internally; route children can call useCommands without an extra shell-level mount.',
|
|
154
|
+
'headerCenter and banners keep route-level chrome in the shell band instead of inside the main pane.',
|
|
155
|
+
'mainLayout and mainClassName let full-bleed routes reuse AppShell mobile chrome without dropping down to primitives.',
|
|
124
156
|
'PanelViewsProvider wraps children with the same panelViews and defaultPanelView for nested route registrations.',
|
|
125
157
|
'renderLink composes Next Link by forwarding Meda linkProps before setting framework-specific props.',
|
|
126
158
|
],
|
|
@@ -7,7 +7,7 @@ function DefaultBrandMark() {
|
|
|
7
7
|
}
|
|
8
8
|
export function AppShellAuth({ children, title, description, brandName = 'Meda', brandMark, eyebrow, preview, actions, }) {
|
|
9
9
|
const resolvedBrandMark = brandMark ?? _jsx(DefaultBrandMark, {});
|
|
10
|
-
return (_jsxs("section", { "data-testid": "app-shell-auth", className: "grid min-h-screen overflow-hidden bg-background text-foreground lg:grid-cols-[minmax(0,1.08fr)_minmax(420px,0.92fr)]", children: [_jsxs("div", { className: "relative flex min-h-[24rem] flex-col overflow-hidden bg-[radial-gradient(circle_at_24%_18%,var(--
|
|
10
|
+
return (_jsxs("section", { "data-testid": "app-shell-auth", className: "grid min-h-screen overflow-hidden bg-background text-foreground lg:grid-cols-[minmax(0,1.08fr)_minmax(420px,0.92fr)]", children: [_jsxs("div", { "data-meda-auth-marketing-panel": "", className: "relative flex min-h-[24rem] flex-col overflow-hidden bg-[radial-gradient(circle_at_24%_18%,var(--auth-gradient-primary)_0,transparent_25%),radial-gradient(circle_at_84%_24%,var(--auth-gradient-secondary)_0,transparent_28%),linear-gradient(135deg,var(--auth-gradient-base),var(--auth-gradient-secondary)_42%,var(--auth-gradient-primary))] px-6 py-6 text-white sm:min-h-[30rem] sm:px-8 lg:min-h-screen lg:px-10 lg:py-8", children: [_jsx("div", { className: "pointer-events-none absolute inset-0 opacity-[0.07]", style: {
|
|
11
11
|
backgroundImage: "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
|
|
12
12
|
} }), _jsxs("div", { className: "relative z-10 flex items-center gap-3", children: [resolvedBrandMark, _jsx("span", { className: "text-lg font-semibold tracking-normal", children: brandName })] }), _jsxs("div", { className: "relative z-10 flex flex-1 flex-col justify-center gap-8 py-10 lg:py-12", children: [_jsxs("div", { className: "max-w-2xl", children: [eyebrow && (_jsx("p", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.12em] text-white/70", children: eyebrow })), _jsx("h1", { className: "text-4xl font-bold leading-tight tracking-normal text-white sm:text-5xl", children: title }), description && (_jsx("p", { className: "mt-4 max-w-xl text-base leading-7 text-white/72 sm:text-lg", children: description }))] }), _jsx("div", { className: "w-full max-w-3xl", children: preview ?? _jsx(DefaultAuthPreview, {}) })] })] }), _jsxs("div", { className: "relative flex min-h-screen flex-col bg-background px-6 py-6 sm:px-8 lg:px-12", children: [actions && _jsx("div", { className: "flex justify-end", children: actions }), _jsxs("div", { className: "mx-auto flex w-full max-w-md flex-1 flex-col justify-center py-10", children: [_jsx("div", { className: "mb-8 flex justify-center lg:hidden", children: resolvedBrandMark }), children] })] })] }));
|
|
13
13
|
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig } from './types.js';
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { AppShellAppTabsConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
|
|
3
3
|
export interface AppShellWorkspaceProps {
|
|
4
4
|
iconRail?: AppShellIconRailConfig;
|
|
5
5
|
contextRail?: AppShellContextRailConfig;
|
|
6
6
|
rightPanel?: AppShellRightPanelConfig;
|
|
7
7
|
workspace?: AppShellWorkspaceConfig;
|
|
8
|
+
appTabs?: AppShellAppTabsConfig;
|
|
8
9
|
globalActions?: ReactNode;
|
|
10
|
+
headerCenter?: ReactNode;
|
|
11
|
+
banners?: ReactNode;
|
|
12
|
+
mainLayout?: ShellMainLayout;
|
|
13
|
+
mainClassName?: string;
|
|
9
14
|
children: ReactNode;
|
|
10
15
|
}
|
|
11
|
-
export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, globalActions, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, banners, mainLayout, mainClassName, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { LayoutGrid, Menu, PanelTop, Sparkles } from 'lucide-react';
|
|
4
|
+
import { useContext } from 'react';
|
|
5
|
+
import { CommandPalette, CommandRegistryContext } from './command-palette.js';
|
|
4
6
|
import { ContextRail } from './context-rail.js';
|
|
5
7
|
import { IconRail } from './icon-rail.js';
|
|
6
8
|
import { MobileBottomNav } from './internal/mobile-bottom-nav.js';
|
|
@@ -12,16 +14,15 @@ import { ShellHeader } from './shell-header.js';
|
|
|
12
14
|
import { ShellMain } from './shell-main.js';
|
|
13
15
|
import { useShellViewport } from './use-shell-viewport.js';
|
|
14
16
|
const EMPTY_PANEL_VIEWS = [];
|
|
15
|
-
export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, globalActions, children, }) {
|
|
17
|
+
export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, banners, mainLayout, mainClassName, children, }) {
|
|
16
18
|
const viewport = useShellViewport();
|
|
17
19
|
const isMobile = viewport === 'mobile';
|
|
18
20
|
const staticPanelViews = rightPanel?.panelViews ?? EMPTY_PANEL_VIEWS;
|
|
19
21
|
const resolvedRightPanel = useResolvedPanelViews(staticPanelViews, rightPanel?.defaultView);
|
|
20
22
|
// Derive the bottom-nav items from the variant config so each button maps
|
|
21
|
-
// to a drawer that actually has content.
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
const navItems = buildMobileNavItems(iconRail, contextRail, resolvedRightPanel.panelViews);
|
|
23
|
+
// to a drawer that actually has content. Menu is always available because
|
|
24
|
+
// the mobile drawer now carries workspace actions and the theme toggle.
|
|
25
|
+
const navItems = buildMobileNavItems(contextRail, resolvedRightPanel.panelViews);
|
|
25
26
|
const hasDrawerContent = navItems.length > 0;
|
|
26
27
|
// Mobile menu drawer needs both main and utility items — desktop IconRail
|
|
27
28
|
// shows both, so dropping utilityItems here would orphan items like Help/
|
|
@@ -37,13 +38,14 @@ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace
|
|
|
37
38
|
// rendered without an explicit-height ancestor (tests, direct imports). The
|
|
38
39
|
// <AppShell> wrapper already enforces h-screen for the workspace variant, so
|
|
39
40
|
// nested viewport-height divs collapse cleanly — no double-scroll.
|
|
40
|
-
|
|
41
|
+
const commandRegistry = useContext(CommandRegistryContext);
|
|
42
|
+
const shell = (_jsxs("div", { className: "flex h-screen flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions })) : (_jsx(ShellHeader, { globalActions: globalActions, headerCenter: headerCenter, appTabsRenderLink: appTabs?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter })), banners ? (_jsx("div", { "data-meda-banners": "", className: "flex-shrink-0", children: banners })) : (false), _jsxs("div", { className: "relative flex flex-1 overflow-hidden", children: [!isMobile && iconRail && (_jsx(IconRail, { mainItems: iconRail.mainItems, utilityItems: iconRail.utilityItems, footer: iconRail.footer, activeId: iconRail.activeId, renderLink: iconRail.renderLink })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId })), _jsx(ShellMain, { layout: mainLayout ?? 'workspace', className: mainClassName, children: children }), !isMobile && resolvedRightPanel.panelViews.length > 0 && (_jsx(RightPanel, { panelViews: staticPanelViews, defaultView: rightPanel?.defaultView }))] }), isMobile && hasDrawerContent && _jsx(MobileBottomNav, { items: navItems }), isMobile && hasDrawerContent && (_jsx(MobileDrawers, { menuItems: mobileMenuItems, menuActiveId: iconRail?.activeId, menuRenderLink: iconRail?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, module: contextRail?.module, moduleAppId: contextRail?.appId, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }));
|
|
43
|
+
return commandRegistry ? shell : _jsx(CommandPalette, { children: shell });
|
|
41
44
|
}
|
|
42
|
-
function buildMobileNavItems(
|
|
43
|
-
const items = [
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
45
|
+
function buildMobileNavItems(contextRail, panelViews) {
|
|
46
|
+
const items = [
|
|
47
|
+
{ id: 'menu', label: 'Menu', icon: Menu, opens: 'menu-drawer' },
|
|
48
|
+
];
|
|
47
49
|
if (contextRail?.module &&
|
|
48
50
|
((contextRail.module.items ?? []).length > 0 || Boolean(contextRail.module.render))) {
|
|
49
51
|
items.push({ id: 'module', label: 'Module', icon: LayoutGrid, opens: 'module-drawer' });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig } from './types.js';
|
|
2
|
+
import type { AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
|
|
3
3
|
interface AppShellBaseProps {
|
|
4
4
|
children: ReactNode;
|
|
5
5
|
className?: string;
|
|
@@ -21,7 +21,31 @@ export type AppShellProps = AppShellBaseProps & ({
|
|
|
21
21
|
* `menuItems` is provided. The theme toggle is preserved automatically.
|
|
22
22
|
*/
|
|
23
23
|
workspace?: AppShellWorkspaceConfig;
|
|
24
|
+
/**
|
|
25
|
+
* Optional application-tab rendering config, used for router-specific
|
|
26
|
+
* link integration.
|
|
27
|
+
*/
|
|
28
|
+
appTabs?: AppShellAppTabsConfig;
|
|
24
29
|
globalActions?: ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* Optional center-region header content. Replaces the default
|
|
32
|
+
* application tabs when provided.
|
|
33
|
+
*/
|
|
34
|
+
headerCenter?: ReactNode;
|
|
35
|
+
/**
|
|
36
|
+
* Optional chrome-level content rendered below the header and above
|
|
37
|
+
* the workspace rail row.
|
|
38
|
+
*/
|
|
39
|
+
banners?: ReactNode;
|
|
40
|
+
/**
|
|
41
|
+
* Layout passed through to the workspace shell's main scroll region.
|
|
42
|
+
* Defaults to `workspace`.
|
|
43
|
+
*/
|
|
44
|
+
mainLayout?: ShellMainLayout;
|
|
45
|
+
/**
|
|
46
|
+
* Optional className for the workspace shell's main scroll region.
|
|
47
|
+
*/
|
|
48
|
+
mainClassName?: string;
|
|
25
49
|
} | {
|
|
26
50
|
variant: 'chat';
|
|
27
51
|
globalActions?: ReactNode;
|
package/dist/shell/app-shell.js
CHANGED
|
@@ -16,7 +16,7 @@ export function AppShell(props) {
|
|
|
16
16
|
case 'auth':
|
|
17
17
|
return wrapper(_jsx(AppShellAuth, { ...resolveAuthConfig(props), children: props.children }));
|
|
18
18
|
case 'workspace':
|
|
19
|
-
return wrapper(_jsx(AppShellWorkspace, { iconRail: props.iconRail, contextRail: props.contextRail, rightPanel: props.rightPanel, workspace: props.workspace, globalActions: props.globalActions, children: props.children }));
|
|
19
|
+
return wrapper(_jsx(AppShellWorkspace, { iconRail: props.iconRail, contextRail: props.contextRail, rightPanel: props.rightPanel, workspace: props.workspace, appTabs: props.appTabs, globalActions: props.globalActions, headerCenter: props.headerCenter, banners: props.banners, mainLayout: props.mainLayout, mainClassName: props.mainClassName, children: props.children }));
|
|
20
20
|
case 'chat':
|
|
21
21
|
return wrapper(_jsx(AppShellChat, { globalActions: props.globalActions, children: props.children }));
|
|
22
22
|
}
|
package/dist/shell/index.d.ts
CHANGED
|
@@ -20,11 +20,12 @@ export type { RailDropZonesProps } from './rail-drop-zones.js';
|
|
|
20
20
|
export { RailDropZones } from './rail-drop-zones.js';
|
|
21
21
|
export { ResizableHandle, ResizableShell, ResizableShellPanel } from './resizable-shell.js';
|
|
22
22
|
export { RightPanel } from './right-panel.js';
|
|
23
|
+
export type { AppTabsProps } from './shell-header.js';
|
|
23
24
|
export { AppTabs, PanelToggle, ShellHeader, WorkspaceSwitcher } from './shell-header.js';
|
|
24
25
|
export { ShellMain } from './shell-main.js';
|
|
25
26
|
export type { MedaShellProviderProps } from './shell-provider.js';
|
|
26
27
|
export { MedaShellProvider, useMedaShell, useShellSelection } from './shell-provider.js';
|
|
27
28
|
export { DefaultThemeProvider, ThemeToggle, useTheme } from './theme.js';
|
|
28
29
|
export { NextThemesAdapter } from './theme-next-themes.js';
|
|
29
|
-
export type { AppDefinition, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellVariant, AppShellWorkspaceConfig, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
|
|
30
|
+
export type { AppDefinition, AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellVariant, AppShellWorkspaceConfig, AppTabRenderLinkArgs, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
|
|
30
31
|
export { useShellViewport } from './use-shell-viewport.js';
|
package/dist/shell/index.js
CHANGED
|
@@ -24,7 +24,6 @@ export { RailDropSlot } from './rail-drop-slot.js';
|
|
|
24
24
|
export { RailDropZones } from './rail-drop-zones.js';
|
|
25
25
|
export { ResizableHandle, ResizableShell, ResizableShellPanel } from './resizable-shell.js';
|
|
26
26
|
export { RightPanel } from './right-panel.js';
|
|
27
|
-
// Header (and its individual children for advanced composition)
|
|
28
27
|
export { AppTabs, PanelToggle, ShellHeader, WorkspaceSwitcher } from './shell-header.js';
|
|
29
28
|
export { ShellMain } from './shell-main.js';
|
|
30
29
|
// Provider + hooks
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { IconRailItem, IconRailProps } from '../icon-rail.js';
|
|
3
|
-
import type { ContextModule, PanelView } from '../types.js';
|
|
3
|
+
import type { ContextModule, PanelView, WorkspaceMenuItem } from '../types.js';
|
|
4
4
|
export interface MobileDrawersProps {
|
|
5
5
|
/** Menu drawer source (icon-rail items). */
|
|
6
6
|
menuItems?: IconRailItem[];
|
|
@@ -8,6 +8,10 @@ export interface MobileDrawersProps {
|
|
|
8
8
|
menuActiveId?: string;
|
|
9
9
|
/** Custom menu link renderer, sourced from icon rail config. */
|
|
10
10
|
menuRenderLink?: IconRailProps['renderLink'];
|
|
11
|
+
/** Workspace-level menu items rendered after icon rail links on mobile. */
|
|
12
|
+
workspaceMenuItems?: WorkspaceMenuItem[];
|
|
13
|
+
/** Workspace-level footer rendered after workspace items and the theme toggle. */
|
|
14
|
+
workspaceMenuFooter?: ReactNode;
|
|
11
15
|
/** Module drawer source (current app's context-rail module). */
|
|
12
16
|
module?: ContextModule;
|
|
13
17
|
/** App id used when rendering module custom content. */
|
|
@@ -29,4 +33,4 @@ export interface MobileDrawersProps {
|
|
|
29
33
|
* any custom-content drawers. Mount once near the AppShell root; drawers
|
|
30
34
|
* open/close via `ctx.mobileDrawer.open` provider state.
|
|
31
35
|
*/
|
|
32
|
-
export declare function MobileDrawers({ menuItems, menuActiveId, menuRenderLink, module, moduleAppId, panelViews, defaultView, customContent, }: MobileDrawersProps): import("react/jsx-runtime").JSX.Element;
|
|
36
|
+
export declare function MobileDrawers({ menuItems, menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, panelViews, defaultView, customContent, }: MobileDrawersProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { Monitor, Moon, Sun } from 'lucide-react';
|
|
4
|
+
import { cloneElement, createElement, Fragment, isValidElement, useEffect, } from 'react';
|
|
4
5
|
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from '../../components/ui/drawer.js';
|
|
5
6
|
import { cn } from '../../lib/utils.js';
|
|
6
7
|
import { useMedaShell } from '../shell-provider.js';
|
|
8
|
+
import { useTheme } from '../theme.js';
|
|
7
9
|
/**
|
|
8
10
|
* Renders all four mobile drawer slots (Menu / Module / Panels / AI) plus
|
|
9
11
|
* any custom-content drawers. Mount once near the AppShell root; drawers
|
|
10
12
|
* open/close via `ctx.mobileDrawer.open` provider state.
|
|
11
13
|
*/
|
|
12
|
-
export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, module, moduleAppId, panelViews = [], defaultView, customContent = {}, }) {
|
|
14
|
+
export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, panelViews = [], defaultView, customContent = {}, }) {
|
|
13
15
|
const ctx = useMedaShell();
|
|
14
16
|
const open = ctx.mobileDrawer.open;
|
|
15
17
|
const setOpen = ctx.mobileDrawer.setOpen;
|
|
@@ -22,13 +24,15 @@ export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, mo
|
|
|
22
24
|
workspaceId: ctx.workspace.id,
|
|
23
25
|
appId: moduleAppId ?? ctx.activeAppId,
|
|
24
26
|
};
|
|
25
|
-
return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems, activeId: menuActiveId, renderLink: menuRenderLink }), _jsx(ModuleDrawer, { open: open === 'module-drawer', onClose: close, module: module, renderCtx: moduleRenderCtx }), _jsx(PanelsDrawer, { open: open === 'panels-drawer', onClose: close, panelViews: panelViews, defaultView: defaultView, renderCtx: renderCtx }), _jsx(AiDrawer, { open: open === 'ai-drawer', onClose: close, panelViews: panelViews, renderCtx: renderCtx }), Object.entries(customContent).map(([id, renderFn]) => (_jsx(Drawer, { open: open === id, onOpenChange: (o) => !o && close(), direction: "bottom", children: _jsx(DrawerContent, { children: renderFn(close) }) }, id)))] }));
|
|
27
|
+
return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems, activeId: menuActiveId, renderLink: menuRenderLink, workspaceItems: workspaceMenuItems, workspaceFooter: workspaceMenuFooter }), _jsx(ModuleDrawer, { open: open === 'module-drawer', onClose: close, module: module, renderCtx: moduleRenderCtx }), _jsx(PanelsDrawer, { open: open === 'panels-drawer', onClose: close, panelViews: panelViews, defaultView: defaultView, renderCtx: renderCtx }), _jsx(AiDrawer, { open: open === 'ai-drawer', onClose: close, panelViews: panelViews, renderCtx: renderCtx }), Object.entries(customContent).map(([id, renderFn]) => (_jsx(Drawer, { open: open === id, onOpenChange: (o) => !o && close(), direction: "bottom", children: _jsx(DrawerContent, { children: renderFn(close) }) }, id)))] }));
|
|
26
28
|
}
|
|
27
29
|
// ---------------------------------------------------------------------------
|
|
28
30
|
// Internal sub-drawers
|
|
29
31
|
// ---------------------------------------------------------------------------
|
|
30
|
-
function MenuDrawer({ open, onClose, items, activeId, renderLink, }) {
|
|
31
|
-
|
|
32
|
+
function MenuDrawer({ open, onClose, items, activeId, renderLink, workspaceItems, workspaceFooter, }) {
|
|
33
|
+
const hasIconItems = items.length > 0;
|
|
34
|
+
const hasWorkspaceItems = Array.isArray(workspaceItems) && workspaceItems.length > 0;
|
|
35
|
+
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "left", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: "Menu" }), _jsx(DrawerDescription, { className: "sr-only", children: "Switch between primary app areas." })] }), _jsxs("div", { className: "flex flex-col gap-2 p-2", children: [hasIconItems && (_jsx("nav", { "aria-label": "Primary navigation", className: "flex flex-col gap-0.5", children: items.map((item) => (_jsx(MenuDrawerItem, { item: item, isActive: item.id === activeId, onClose: onClose, renderLink: renderLink }, item.id))) })), (hasIconItems || hasWorkspaceItems) && _jsx("div", { className: "h-px bg-border" }), _jsxs("nav", { "aria-label": "Workspace menu", className: "flex flex-col gap-0.5", children: [workspaceItems?.map((item) => (_jsxs(Fragment, { children: [_jsx(WorkspaceMenuDrawerItem, { item: item, onClose: onClose }), item.separatorAfter && _jsx("div", { className: "my-1 h-px bg-border" })] }, item.id))), _jsx(MobileThemeMenuItem, { onClose: onClose })] }), workspaceFooter] })] }) }));
|
|
32
36
|
}
|
|
33
37
|
const menuItemClassName = 'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground';
|
|
34
38
|
function MenuDrawerItem({ item, isActive, onClose, renderLink, }) {
|
|
@@ -63,6 +67,49 @@ function closeAfterLinkClick(link, onClose) {
|
|
|
63
67
|
},
|
|
64
68
|
});
|
|
65
69
|
}
|
|
70
|
+
function renderWorkspaceIcon(icon) {
|
|
71
|
+
if (icon == null)
|
|
72
|
+
return null;
|
|
73
|
+
if (isValidElement(icon))
|
|
74
|
+
return icon;
|
|
75
|
+
if (typeof icon === 'function') {
|
|
76
|
+
return createElement(icon, { size: 18, 'aria-hidden': true });
|
|
77
|
+
}
|
|
78
|
+
if (typeof icon === 'object') {
|
|
79
|
+
const candidate = icon;
|
|
80
|
+
if (candidate.$$typeof != null) {
|
|
81
|
+
return createElement(icon, { size: 18, 'aria-hidden': true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return icon;
|
|
85
|
+
}
|
|
86
|
+
function WorkspaceMenuDrawerItem({ item, onClose, }) {
|
|
87
|
+
const children = (_jsxs(_Fragment, { children: [renderWorkspaceIcon(item.icon), _jsx("span", { children: item.label })] }));
|
|
88
|
+
const className = cn(menuItemClassName, item.variant === 'destructive' && 'text-destructive hover:text-destructive');
|
|
89
|
+
const handleClick = () => {
|
|
90
|
+
item.onClick?.();
|
|
91
|
+
onClose();
|
|
92
|
+
};
|
|
93
|
+
if (item.href != null) {
|
|
94
|
+
return closeAfterLinkClick(_jsx("a", { href: item.href, className: className, "data-variant": item.variant ?? 'default', onClick: () => item.onClick?.(), children: children }), onClose);
|
|
95
|
+
}
|
|
96
|
+
return (_jsx("button", { type: "button", "data-variant": item.variant ?? 'default', className: className, onClick: handleClick, children: children }));
|
|
97
|
+
}
|
|
98
|
+
const NEXT_THEME = { light: 'dark', dark: 'system', system: 'light' };
|
|
99
|
+
const THEME_ICON = { light: Sun, dark: Moon, system: Monitor };
|
|
100
|
+
const THEME_LABEL = {
|
|
101
|
+
light: 'Switch to dark theme',
|
|
102
|
+
dark: 'Switch to system theme',
|
|
103
|
+
system: 'Switch to light theme',
|
|
104
|
+
};
|
|
105
|
+
function MobileThemeMenuItem({ onClose }) {
|
|
106
|
+
const { theme, setTheme } = useTheme();
|
|
107
|
+
const Icon = THEME_ICON[theme];
|
|
108
|
+
return (_jsxs("button", { type: "button", "aria-label": THEME_LABEL[theme], className: menuItemClassName, onClick: () => {
|
|
109
|
+
setTheme(NEXT_THEME[theme]);
|
|
110
|
+
onClose();
|
|
111
|
+
}, children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: THEME_LABEL[theme] })] }));
|
|
112
|
+
}
|
|
66
113
|
function ModuleDrawer({ open, onClose, module, renderCtx, }) {
|
|
67
114
|
const items = module?.items ?? [];
|
|
68
115
|
if (!module || (items.length === 0 && !module.render))
|
|
@@ -90,7 +137,7 @@ function PanelsDrawer({ open, onClose, panelViews, defaultView, renderCtx, }) {
|
|
|
90
137
|
const active = panelViews.find((v) => v.id === activeView) ??
|
|
91
138
|
(defaultView ? panelViews.find((v) => v.id === defaultView) : undefined) ??
|
|
92
139
|
panelViews[0];
|
|
93
|
-
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "bottom", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: active?.label ?? 'Panels' }), _jsx(DrawerDescription, { className: "sr-only", children: "Contextual panels for the current module." })] }), panelViews.length > 1 && (_jsx("div", { className: "flex items-center gap-1 border-b border-border px-3 py-2", children: panelViews.map((view) => (_jsx("button", { type: "button", onClick: () => ctx.panel.setActiveView(view.id), "aria-current": view.id === activeView ? 'true' : undefined, className: cn('rounded-md px-2 py-1 text-
|
|
140
|
+
return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "bottom", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: active?.label ?? 'Panels' }), _jsx(DrawerDescription, { className: "sr-only", children: "Contextual panels for the current module." })] }), panelViews.length > 1 && (_jsx("div", { className: "flex items-center gap-1 border-b border-border px-3 py-2", children: panelViews.map((view) => (_jsx("button", { type: "button", onClick: () => ctx.panel.setActiveView(view.id), "aria-current": view.id === activeView ? 'true' : undefined, className: cn('rounded-md px-2 py-1 text-sm', view.id === activeView
|
|
94
141
|
? 'bg-accent text-accent-foreground'
|
|
95
142
|
: 'text-muted-foreground hover:bg-accent'), children: view.label }, view.id))) })), _jsx("div", { className: "flex-1 overflow-y-auto p-3", children: active?.render(renderCtx) })] }) }));
|
|
96
143
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
import type { WorkspaceMenuItem } from './types.js';
|
|
2
|
+
import type { AppShellAppTabsConfig, WorkspaceMenuItem } from './types.js';
|
|
3
3
|
export interface WorkspaceSwitcherProps {
|
|
4
4
|
/**
|
|
5
5
|
* Configurable dropdown items. When provided, REPLACES the default
|
|
@@ -19,10 +19,18 @@ export interface WorkspaceSwitcherProps {
|
|
|
19
19
|
workspaceMenuFooter?: ReactNode;
|
|
20
20
|
}
|
|
21
21
|
export declare function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, }?: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
|
|
22
|
-
export
|
|
22
|
+
export interface AppTabsProps extends AppShellAppTabsConfig {
|
|
23
|
+
}
|
|
24
|
+
export declare function AppTabs({ renderLink }?: AppTabsProps): import("react/jsx-runtime").JSX.Element;
|
|
23
25
|
export declare function PanelToggle(): import("react/jsx-runtime").JSX.Element;
|
|
24
26
|
export interface ShellHeaderProps {
|
|
25
27
|
globalActions?: ReactNode;
|
|
28
|
+
/**
|
|
29
|
+
* Optional center-region content. Replaces the default application tabs when
|
|
30
|
+
* provided.
|
|
31
|
+
*/
|
|
32
|
+
headerCenter?: ReactNode;
|
|
33
|
+
appTabsRenderLink?: AppShellAppTabsConfig['renderLink'];
|
|
26
34
|
className?: string;
|
|
27
35
|
/**
|
|
28
36
|
* Forwarded to the internal `<WorkspaceSwitcher>`. See
|
|
@@ -31,4 +39,4 @@ export interface ShellHeaderProps {
|
|
|
31
39
|
workspaceMenuItems?: WorkspaceMenuItem[];
|
|
32
40
|
workspaceMenuFooter?: ReactNode;
|
|
33
41
|
}
|
|
34
|
-
export declare function ShellHeader({ globalActions, className, workspaceMenuItems, workspaceMenuFooter, }?: ShellHeaderProps): import("react/jsx-runtime").JSX.Element | null;
|
|
42
|
+
export declare function ShellHeader({ globalActions, headerCenter, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, }?: ShellHeaderProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -19,7 +19,7 @@ function ThemeToggleMenuItem() {
|
|
|
19
19
|
const Icon = THEME_ICON[theme];
|
|
20
20
|
return (_jsxs(DropdownMenuItem, { onClick: () => setTheme(NEXT_THEME[theme]), children: [_jsx(Icon, { size: 16, "aria-hidden": "true" }), THEME_LABEL[theme]] }));
|
|
21
21
|
}
|
|
22
|
-
function
|
|
22
|
+
function renderShellIcon(icon) {
|
|
23
23
|
if (icon == null)
|
|
24
24
|
return null;
|
|
25
25
|
if (isValidElement(icon))
|
|
@@ -49,7 +49,7 @@ function renderConfiguredItem(item) {
|
|
|
49
49
|
// label children below and configured icons would silently disappear.
|
|
50
50
|
// biome-ignore lint/a11y/useAnchorContent: children are injected at render time by Base UI's `render` prop
|
|
51
51
|
const renderLink = item.href != null ? _jsx("a", { href: item.href }) : undefined;
|
|
52
|
-
return (_jsxs(DropdownMenuItem, { render: renderLink, "data-variant": item.variant ?? 'default', className: item.variant === 'destructive' ? 'text-destructive' : undefined, onClick: handleSelect, children: [
|
|
52
|
+
return (_jsxs(DropdownMenuItem, { render: renderLink, "data-variant": item.variant ?? 'default', className: item.variant === 'destructive' ? 'text-destructive' : undefined, onClick: handleSelect, children: [renderShellIcon(item.icon), item.label] }));
|
|
53
53
|
}
|
|
54
54
|
export function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, } = {}) {
|
|
55
55
|
const { workspace, workspaces } = useMedaShell();
|
|
@@ -57,21 +57,33 @@ export function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter,
|
|
|
57
57
|
const useConfiguredItems = Array.isArray(menuItems);
|
|
58
58
|
return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", className: "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent" }), children: [workspace.icon != null && (_jsx("span", { className: "shrink-0", "aria-hidden": "true", children: workspace.icon })), _jsx("span", { children: workspace.name }), _jsx(ChevronDown, { size: 14, "aria-hidden": "true" })] }), _jsxs(DropdownMenuContent, { className: "min-w-[200px]", children: [workspaces.length > 0 && (_jsxs(_Fragment, { children: [workspaces.map((ws) => (_jsxs(DropdownMenuItem, { children: [ws.icon != null && _jsx("span", { "aria-hidden": "true", children: ws.icon }), ws.name] }, ws.id))), _jsx(DropdownMenuSeparator, {})] })), useConfiguredItems ? (menuItems.map((item) => (_jsxs(Fragment, { children: [renderConfiguredItem(item), item.separatorAfter && _jsx(DropdownMenuSeparator, {})] }, item.id)))) : (_jsxs(_Fragment, { children: [_jsx(DropdownMenuItem, { children: "Manage workspaces" }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Settings" }), _jsx(DropdownMenuItem, { children: "Profile" })] })), _jsx(DropdownMenuSeparator, {}), _jsx(ThemeToggleMenuItem, {}), !useConfiguredItems && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Sign out" })] })), resolvedFooter] })] }));
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
// AppTabs
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
export function AppTabs() {
|
|
60
|
+
export function AppTabs({ renderLink } = {}) {
|
|
64
61
|
const { apps, activeAppId, setActiveApp } = useMedaShell();
|
|
65
62
|
return (_jsx("nav", { "aria-label": "Applications", className: "flex items-center", children: apps.map((app) => {
|
|
66
63
|
const isActive = app.id === activeAppId;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
64
|
+
const className = cn('flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors', isActive
|
|
65
|
+
? 'border-b-2 border-primary text-foreground'
|
|
66
|
+
: 'text-muted-foreground hover:text-foreground');
|
|
67
|
+
const children = (_jsxs(_Fragment, { children: [renderShellIcon(app.icon), app.label] }));
|
|
68
|
+
const handleClick = () => {
|
|
69
|
+
setActiveApp(app.id);
|
|
70
|
+
};
|
|
71
|
+
if (renderLink && app.to) {
|
|
72
|
+
return (_jsx(Fragment, { children: renderLink({
|
|
73
|
+
app,
|
|
74
|
+
isActive,
|
|
75
|
+
className,
|
|
76
|
+
children,
|
|
77
|
+
linkProps: {
|
|
78
|
+
href: app.to,
|
|
79
|
+
className,
|
|
80
|
+
children,
|
|
81
|
+
'aria-current': isActive ? 'page' : undefined,
|
|
82
|
+
onClick: handleClick,
|
|
83
|
+
},
|
|
84
|
+
}) }, app.id));
|
|
85
|
+
}
|
|
86
|
+
return (_jsx("button", { type: "button", "aria-current": isActive ? 'page' : undefined, onClick: handleClick, onMouseEnter: () => { }, className: className, children: children }, app.id));
|
|
75
87
|
}) }));
|
|
76
88
|
}
|
|
77
89
|
// ---------------------------------------------------------------------------
|
|
@@ -85,9 +97,9 @@ export function PanelToggle() {
|
|
|
85
97
|
};
|
|
86
98
|
return (_jsx("button", { type: "button", "aria-label": isOpen ? 'Close right panel' : 'Open right panel', onClick: handleClick, className: cn('inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors', isOpen ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent'), children: isOpen ? (_jsx(PanelRightClose, { size: 18, "aria-hidden": "true" })) : (_jsx(PanelRightOpen, { size: 18, "aria-hidden": "true" })) }));
|
|
87
99
|
}
|
|
88
|
-
export function ShellHeader({ globalActions, className, workspaceMenuItems, workspaceMenuFooter, } = {}) {
|
|
100
|
+
export function ShellHeader({ globalActions, headerCenter, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, } = {}) {
|
|
89
101
|
const band = useShellViewport();
|
|
90
102
|
if (band === 'mobile')
|
|
91
103
|
return null;
|
|
92
|
-
return (_jsxs("header", { className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'border-b border-border bg-background px-3', className), children: [
|
|
104
|
+
return (_jsxs("header", { className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'gap-3 border-b border-border bg-background px-3', className), children: [_jsx("div", { className: "flex shrink-0 items-center", children: _jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }) }), _jsx("div", { className: "flex min-w-0 flex-1 items-center", children: headerCenter !== undefined ? headerCenter : _jsx(AppTabs, { renderLink: appTabsRenderLink }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [globalActions, _jsx(PanelToggle, {})] })] }));
|
|
93
105
|
}
|
|
@@ -8,9 +8,8 @@ import { useShellViewport } from './use-shell-viewport.js';
|
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
// Theme adapter wiring
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
|
-
// Lazy-loaded so
|
|
12
|
-
//
|
|
13
|
-
// the import.
|
|
11
|
+
// Lazy-loaded so default-adapter consumers do not pay for the compatibility
|
|
12
|
+
// bridge used when themeAdapter='next-themes'.
|
|
14
13
|
const NextThemesAdapter = lazy(() => import('./theme-next-themes.js').then((m) => ({ default: m.NextThemesAdapter })));
|
|
15
14
|
function CustomThemeBridge({ adapter, children }) {
|
|
16
15
|
return _jsx(ThemeCtx.Provider, { value: adapter, children: children });
|
|
@@ -1,24 +1,115 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { useCallback, useEffect, useInsertionEffect, useMemo, useState, } from 'react';
|
|
4
4
|
import { ThemeCtx } from './theme.js';
|
|
5
|
+
const STORAGE_KEY = 'theme';
|
|
6
|
+
const MEDIA_QUERY = '(prefers-color-scheme: dark)';
|
|
7
|
+
const THEMES = ['light', 'dark'];
|
|
5
8
|
function narrow(value) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return
|
|
12
|
-
}
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
return value === 'light' || value === 'dark' || value === 'system' ? value : 'system';
|
|
10
|
+
}
|
|
11
|
+
function getMediaQueryList() {
|
|
12
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
13
|
+
return undefined;
|
|
14
|
+
return window.matchMedia(MEDIA_QUERY);
|
|
15
|
+
}
|
|
16
|
+
function resolveSystemTheme(mql) {
|
|
17
|
+
if (mql)
|
|
18
|
+
return mql.matches ? 'dark' : 'light';
|
|
19
|
+
return getMediaQueryList()?.matches ? 'dark' : 'light';
|
|
20
|
+
}
|
|
21
|
+
function getStoredTheme() {
|
|
22
|
+
if (typeof window === 'undefined')
|
|
23
|
+
return 'system';
|
|
24
|
+
try {
|
|
25
|
+
return narrow(window.localStorage.getItem(STORAGE_KEY));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return 'system';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function applyTheme(theme, resolvedTheme) {
|
|
32
|
+
if (typeof document === 'undefined')
|
|
33
|
+
return;
|
|
34
|
+
const root = document.documentElement;
|
|
35
|
+
const applied = theme === 'system' ? resolvedTheme : theme;
|
|
36
|
+
root.classList.remove(...THEMES);
|
|
37
|
+
root.classList.add(applied);
|
|
38
|
+
root.style.colorScheme = applied;
|
|
39
|
+
}
|
|
40
|
+
function getClientThemeSnapshot() {
|
|
41
|
+
const theme = getStoredTheme();
|
|
42
|
+
return {
|
|
43
|
+
theme,
|
|
44
|
+
resolvedTheme: theme === 'system' ? resolveSystemTheme() : theme,
|
|
19
45
|
};
|
|
20
|
-
|
|
46
|
+
}
|
|
47
|
+
function subscribeToSystemTheme(mql, listener) {
|
|
48
|
+
if (!mql)
|
|
49
|
+
return () => { };
|
|
50
|
+
if (typeof mql.addEventListener === 'function') {
|
|
51
|
+
mql.addEventListener('change', listener);
|
|
52
|
+
return () => mql.removeEventListener('change', listener);
|
|
53
|
+
}
|
|
54
|
+
if (typeof mql.addListener === 'function') {
|
|
55
|
+
mql.addListener(listener);
|
|
56
|
+
return () => mql.removeListener?.(listener);
|
|
57
|
+
}
|
|
58
|
+
return () => { };
|
|
21
59
|
}
|
|
22
60
|
export function NextThemesAdapter({ children }) {
|
|
23
|
-
|
|
61
|
+
const [theme, setThemeState] = useState('system');
|
|
62
|
+
const [resolvedTheme, setResolvedTheme] = useState('light');
|
|
63
|
+
const [hydrated, setHydrated] = useState(false);
|
|
64
|
+
const setTheme = useCallback((next) => {
|
|
65
|
+
setThemeState(next);
|
|
66
|
+
const resolved = next === 'system' ? resolveSystemTheme() : next;
|
|
67
|
+
setResolvedTheme(resolved);
|
|
68
|
+
applyTheme(next, resolved);
|
|
69
|
+
try {
|
|
70
|
+
if (typeof window !== 'undefined')
|
|
71
|
+
window.localStorage.setItem(STORAGE_KEY, next);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Ignore unavailable storage, matching next-themes' best-effort behavior.
|
|
75
|
+
}
|
|
76
|
+
}, []);
|
|
77
|
+
useInsertionEffect(() => {
|
|
78
|
+
const snapshot = getClientThemeSnapshot();
|
|
79
|
+
applyTheme(snapshot.theme, snapshot.resolvedTheme);
|
|
80
|
+
}, []);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const stored = getClientThemeSnapshot();
|
|
83
|
+
setThemeState(stored.theme);
|
|
84
|
+
setResolvedTheme(stored.resolvedTheme);
|
|
85
|
+
setHydrated(true);
|
|
86
|
+
applyTheme(stored.theme, stored.resolvedTheme);
|
|
87
|
+
}, []);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!hydrated)
|
|
90
|
+
return;
|
|
91
|
+
const mql = getMediaQueryList();
|
|
92
|
+
const sync = (_event) => {
|
|
93
|
+
const resolved = theme === 'system' ? resolveSystemTheme(mql) : theme;
|
|
94
|
+
setResolvedTheme(resolved);
|
|
95
|
+
applyTheme(theme, resolved);
|
|
96
|
+
};
|
|
97
|
+
sync();
|
|
98
|
+
return subscribeToSystemTheme(mql, sync);
|
|
99
|
+
}, [hydrated, theme]);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const handleStorage = (event) => {
|
|
102
|
+
if (event.key !== STORAGE_KEY)
|
|
103
|
+
return;
|
|
104
|
+
setThemeState(narrow(event.newValue));
|
|
105
|
+
};
|
|
106
|
+
window.addEventListener('storage', handleStorage);
|
|
107
|
+
return () => window.removeEventListener('storage', handleStorage);
|
|
108
|
+
}, []);
|
|
109
|
+
const value = useMemo(() => ({
|
|
110
|
+
theme,
|
|
111
|
+
setTheme,
|
|
112
|
+
resolvedTheme,
|
|
113
|
+
}), [theme, setTheme, resolvedTheme]);
|
|
114
|
+
return _jsx(ThemeCtx.Provider, { value: value, children: children });
|
|
24
115
|
}
|
package/dist/shell/types.d.ts
CHANGED
|
@@ -3,7 +3,23 @@ import type { AnchorHTMLAttributes, ReactNode } from 'react';
|
|
|
3
3
|
export interface AppDefinition {
|
|
4
4
|
id: string;
|
|
5
5
|
label: string;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Either a Lucide-style icon component or a rendered React node such as a
|
|
8
|
+
* custom brand SVG.
|
|
9
|
+
*/
|
|
10
|
+
icon: LucideIcon | ReactNode;
|
|
11
|
+
/** Optional route target for consumers that render app tabs as links. */
|
|
12
|
+
to?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface AppTabRenderLinkArgs {
|
|
15
|
+
app: AppDefinition;
|
|
16
|
+
isActive: boolean;
|
|
17
|
+
className: string;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
linkProps: AnchorHTMLAttributes<HTMLAnchorElement>;
|
|
20
|
+
}
|
|
21
|
+
export interface AppShellAppTabsConfig {
|
|
22
|
+
renderLink?: (args: AppTabRenderLinkArgs) => ReactNode;
|
|
7
23
|
}
|
|
8
24
|
export interface WorkspaceDefinition {
|
|
9
25
|
id: string;
|
package/dist/styles/theme.css
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
@layer base {
|
|
15
15
|
:root {
|
|
16
16
|
color-scheme: light;
|
|
17
|
+
--auth-gradient-primary: var(--color-brand-500);
|
|
18
|
+
--auth-gradient-secondary: var(--color-brand-700);
|
|
19
|
+
--auth-gradient-base: var(--color-brand-800);
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
.dark,
|
|
@@ -22,6 +25,10 @@
|
|
|
22
25
|
}
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
[data-vaul-drawer][data-state="open"] {
|
|
29
|
+
will-change: auto;
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
@theme inline {
|
|
26
33
|
/* Color primitives — exposed as Tailwind color scales */
|
|
27
34
|
|
|
@@ -26,4 +26,16 @@ describe('theme.css', () => {
|
|
|
26
26
|
it('declares an @source directive that scans meda component output', () => {
|
|
27
27
|
expect(themeCss).toMatch(/@source\s+["']\.\.\/\*\*\/\*\.js["']/);
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
it('declares dedicated auth gradient tokens for consumer overrides', () => {
|
|
31
|
+
expect(themeCss).toContain('--auth-gradient-primary: var(--color-brand-500);');
|
|
32
|
+
expect(themeCss).toContain('--auth-gradient-secondary: var(--color-brand-700);');
|
|
33
|
+
expect(themeCss).toContain('--auth-gradient-base: var(--color-brand-800);');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('neutralizes vaul drawer layer hints after mobile drawers are open', () => {
|
|
37
|
+
expect(themeCss).toMatch(
|
|
38
|
+
/\[data-vaul-drawer\]\[data-state=["']open["']\]\s*\{[^}]*will-change:\s*auto;/s
|
|
39
|
+
);
|
|
40
|
+
});
|
|
29
41
|
});
|